Skip to content

Gallery to Blog Draft: Turn a Finished Gallery into a Blog Post with Google Apps Script and Gemini

For Photographers ·

Tools:Google Workspace, Gemini
Time to build:1-2 hours
Difficulty:Advanced
Prerequisites:Comfortable with a chatbot-drafted outline and comfortable pasting text into a Google Sheet. See the Level 3 guide "Set Up an AI-Assisted Website for SEO and Copy".
Google WorkspaceGemini

What This Builds

A finished gallery that would normally sit there without ever becoming a blog post now gets a first draft written for it automatically, once a day, without you opening a blank document. You mark a gallery "Ready for Blog" in a tracking sheet, and by the next morning a Google Doc exists with a full draft recap, sitting in a folder waiting for your photos and your final edit. The posts that used to get skipped when a busy week hit start actually getting published, because the hardest part, starting from nothing, is already done.

Prerequisites

  • A Google account with access to Google Sheets, Docs and Drive. Apps Script itself is included free with any Google account, or with $14/user/month if you already run the studio on a Workspace plan
  • A Gemini API key from Google AI Studio, created under gemini.google.com. Gemini API usage is billed separately from the consumer Gemini app and by usage rather than a flat monthly plan, so check Google's current API pricing page before running this against many galleries a month
  • Comfortable copying and pasting a script into the Apps Script editor (no programming background needed, just careful copy-pasting)
  • Total ongoing cost for this build: free for the Sheet, Docs and script themselves, plus usage-based Gemini API charges that scale with how many galleries you run through it each month

The Concept

This works like a standing instruction to an assistant who checks one list every morning and writes a draft for any gallery marked ready. The assistant, in this case a script that calls Google's Gemini model, never sees the client's name or the exact date of the shoot. It only sees the session type, the general location, and a few highlight notes you jot down yourself, which is enough to write a recap but not enough to expose anything private. You still add the actual photos and give the draft a final read before it goes anywhere public.


Build It Step by Step

Part 1: Set Up the Gallery Tracker Sheet

  1. Create a new Google Sheet called "Gallery Tracker" with these column headers in row 1: Status, Session Type, Highlights, Public Location or Region, Draft Doc URL, Last Error, Last Run.
  2. Add a row per finished gallery you want turned into a blog post. Set Status to Ready for Blog. In Highlights, jot a short note like "golden hour beach portraits, ring shot, first look reaction." In Public Location or Region, use something as general as "Charleston, SC" rather than a street address.
  3. Do not put the client's name, exact date or venue address anywhere the script will read. Keep a separate internal column for your own reference if you need one, and make sure the script (below) never touches it. Only add a gallery to this tracker once you have confirmed your contract with that client allows using their images and general location for marketing.

Part 2: Install the Script and Connect Gemini

  1. From your Gallery Tracker Sheet, open Extensions > Apps Script.
  2. Delete the placeholder code and paste in the script below.
  3. In the Apps Script editor, open Project Settings > Script Properties and add a property named GEMINI_API_KEY with your actual API key as the value. Never paste the key directly into the script.
  4. In Google AI Studio, copy the current model id behind Gemini 3.1 Flash and paste it in place of the placeholder text inside the MODEL_ID constant near the top of the script.
Copy and paste this
// Google Apps Script: Gallery to Blog Draft
// Runs on a daily time-driven trigger installed in Part 3 below.

// Paste the current model id from AI Studio here. Look up the id behind
// the fast Gemini model recommendation before you paste it in.
const MODEL_ID = 'PASTE_THE_MODEL_ID_FROM_AI_STUDIO_HERE';

// Process at most this many galleries per run. This keeps one run from
// timing out and from burning through a day's worth of API calls at once.
const MAX_PER_RUN = 8;

const TRACKER_SHEET_NAME = 'Gallery Tracker';
const DRAFTS_FOLDER_NAME = 'Blog Drafts';

function processGalleryBlogDrafts() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(TRACKER_SHEET_NAME);
  const data = sheet.getDataRange().getValues();
  const headers = data[0];

  const statusCol = headers.indexOf('Status');
  const sessionTypeCol = headers.indexOf('Session Type');
  const highlightsCol = headers.indexOf('Highlights');
  const locationCol = headers.indexOf('Public Location or Region');
  const draftUrlCol = headers.indexOf('Draft Doc URL');
  const errorCol = headers.indexOf('Last Error');
  const updatedCol = headers.indexOf('Last Run');

  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!apiKey) {
    Logger.log('No GEMINI_API_KEY script property set. Stopping.');
    return;
  }

  let processed = 0;

  for (let row = 1; row < data.length && processed < MAX_PER_RUN; row++) {
    const status = data[row][statusCol];
    if (status !== 'Ready for Blog') continue;

    const sheetRow = row + 1;
    // Count every attempt, including failures, so a run of failing rows
    // still stops at MAX_PER_RUN API calls.
    processed++;

    try {
      const prompt = buildPrompt(
        data[row][sessionTypeCol],
        data[row][highlightsCol],
        data[row][locationCol]
      );

      const draftText = callGemini(prompt, apiKey);
      const doc = createDraftDoc(draftText, data[row][sessionTypeCol]);

      sheet.getRange(sheetRow, draftUrlCol + 1).setValue(doc.getUrl());
      sheet.getRange(sheetRow, statusCol + 1).setValue('Draft Ready');
      sheet.getRange(sheetRow, updatedCol + 1).setValue(new Date());
      sheet.getRange(sheetRow, errorCol + 1).setValue('');

      // Save this row's result right away. A failure on a later row can
      // then never undo or lose what this row already saved.
      SpreadsheetApp.flush();
    } catch (err) {
      sheet.getRange(sheetRow, errorCol + 1).setValue(String(err));
      sheet.getRange(sheetRow, updatedCol + 1).setValue(new Date());
      SpreadsheetApp.flush();
      Logger.log('Row ' + sheetRow + ' failed: ' + err);
      // Status stays "Ready for Blog" so the next run retries this row.
      continue;
    }
  }

  Logger.log('Attempted ' + processed + ' gallery(ies) this run. Rows with a Last Error value stay Ready for Blog and are retried next run.');
}

function buildPrompt(sessionType, highlights, location) {
  return 'Write a 500 to 700 word blog post recap for a photography studio.\n' +
    'Session type: ' + sessionType + '\n' +
    'General location or region (no street address): ' + location + '\n' +
    'Highlights to feature: ' + highlights + '\n' +
    'Do not invent client names or exact dates. Write in a warm, professional ' +
    'voice with a headline, three to four short sections, and a closing call ' +
    'to action to inquire about booking a similar session.';
}

function callGemini(prompt, apiKey) {
  const url = 'https://generativelanguage.googleapis.com/v1beta/models/' +
    MODEL_ID + ':generateContent?key=' + apiKey;

  const payload = {
    contents: [{ parts: [{ text: prompt }] }]
  };

  const response = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  const json = JSON.parse(response.getContentText());
  if (!json.candidates || !json.candidates.length) {
    throw new Error('No draft returned from Gemini: ' + response.getContentText());
  }
  return json.candidates[0].content.parts[0].text;
}

function createDraftDoc(draftText, sessionType) {
  const folders = DriveApp.getFoldersByName(DRAFTS_FOLDER_NAME);
  const folder = folders.hasNext() ? folders.next() : DriveApp.createFolder(DRAFTS_FOLDER_NAME);

  const title = sessionType + ' Blog Draft - ' + new Date().toDateString();
  const doc = DocumentApp.create(title);
  const body = doc.getBody();
  body.appendParagraph('DRAFT: AI-generated. Review, add images and edit before publishing.');
  body.appendParagraph(draftText);
  doc.saveAndClose();

  DriveApp.getFileById(doc.getId()).moveTo(folder);

  return doc;
}

Part 3: Install the Daily Trigger and Test It

  1. Before scheduling anything, run processGalleryBlogDrafts once manually from the Apps Script editor with a single test row in your tracker. Check the Executions log in the editor for errors, and confirm a new Doc appears in a "Blog Drafts" folder in your Drive.
  2. Once that test row shows Draft Ready and a working Doc link, open the clock icon (Triggers) in the Apps Script editor, add a trigger for processGalleryBlogDrafts, choose a time-driven trigger, and set it to run once a day.
  3. Grant the permissions Google asks for the first time the trigger runs. These cover the Sheet, Docs and Drive access the script needs.

Real Example: A Wedding Gallery Ready for Recap

Setup: A wedding photographer adds a row for a recently delivered gallery: Session Type "Wedding," Highlights "golden hour portraits at the vineyard, first dance, sparkler exit," Public Location or Region "Sonoma County, CA," Status "Ready for Blog."

Input: The next morning's scheduled run finds that row and calls Gemini with only those three fields, no client name or exact date.

Output: A new Google Doc titled "Wedding Blog Draft - [today's date]" appears in the Blog Drafts folder, containing a full recap draft with a headline and several sections. The tracker row updates to "Draft Ready" with a link to the Doc.

Time saved: A blog post outline from a finished gallery normally takes 30 to 45 minutes to write from a blank page. This script does not replace your review and photo selection, but it removes the blank page entirely, so what is left is editing a draft rather than starting one.


What to Do When It Breaks

  • A row's status never changes from "Ready for Blog" → check that row's Last Error column. The most common cause is a missing or expired GEMINI_API_KEY script property, which fails every row at once rather than just one.
  • The Doc gets created but lands in "My Drive" instead of the Blog Drafts folder → this usually means a folder with a slightly different name already exists. Rename it to match DRAFTS_FOLDER_NAME exactly, or update the constant to match your folder's real name.
  • The draft reads generic or repeats the same phrasing across galleries → add more specific detail to that row's Highlights column. The output only reflects what you put in that field.
  • Silent failure: Apps Script disables a time-driven trigger after enough consecutive failures, and your Google account's authorization for the script can also lapse without any visible warning inside the Sheet itself. You will simply stop seeing new "Draft Ready" rows appear even as galleries pile up with "Ready for Blog" status. Check the Apps Script editor's Executions log every week or two, and watch for Google's automated email about a disabled trigger.

Variations

  • Simpler version: Skip the Doc and Drive steps and have the script write the draft text directly into a column in the tracker sheet for you to copy into your blog editor by hand.
  • Extended version: Add a step that also pulls two or three image file names from a linked Drive folder and inserts them as placeholders in the Doc, so the layout is closer to finished before you touch it.

What to Do Next

  • This week: Run it against two or three already-delivered galleries and see how much you end up rewriting versus keeping.
  • This month: Set the trigger's daily time to whenever you actually check email in the morning, so a fresh draft is waiting exactly when you look for it.
  • Advanced: Connect this to the scheduled follow-up and review-request system, so a gallery's delivery date starts both the client follow-up sequence and this blog draft at the same time.

Advanced guide for photographer professionals. These techniques use more sophisticated AI features that may require paid subscriptions.