Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
REACT_APP_PROJECTS_API_URL=
15 changes: 15 additions & 0 deletions docs/projects-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Projects publishing workflow

The committed file `src/data/projects.json` is the website's only project-data source. Editing the spreadsheet does not change the live website.

## Publish spreadsheet changes

1. Edit the **Projects for website** sheet.
2. Give every project a permanent, unique **Project ID** such as `chefost`. Never reuse or rename an ID.
3. Run `npm run import-projects` from the repository root.
4. Review the diff in `src/data/projects.json`.
5. Commit and push the JSON change through the normal review process.

The importer updates records with matching IDs and adds new records. It deliberately preserves JSON records that are absent from the spreadsheet, so historical projects cannot be deleted accidentally by removing a row. Duplicate IDs, missing titles, and missing summaries stop the import without changing the JSON file.

The Apps Script is read-only. It must be run manually through the import command; spreadsheet edits never trigger a deployment or website update.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"optimize:images": "node scripts/optimize-images.js",
"optimize:images:clean": "node scripts/optimize-images.js --clean",
"optimize:images:help": "node scripts/optimize-images.js --help",
"import-projects": "node scripts/import-projects.js",
"test": "react-scripts test",
"eject": "react-scripts eject",
"predeploy": "npm run build",
Expand Down
142 changes: 142 additions & 0 deletions scripts/google-apps-script/Code.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
const SPREADSHEET_ID = "1y95UWwpNNwWkoivU2j3jt1q3JwBG-OCpxUjVfW-WV5w";
const PROJECTS_SHEET_NAME = "Projects for website";

function doGet(event) {
const callback = sanitizeCallback_(event && event.parameter && event.parameter.callback);
const payload = JSON.stringify({
status: "ok",
projects: readProjects_(),
updatedAt: new Date().toISOString(),
});

if (callback) {
return ContentService
.createTextOutput(callback + "(" + payload + ");")
.setMimeType(ContentService.MimeType.JAVASCRIPT);
}

return ContentService
.createTextOutput(payload)
.setMimeType(ContentService.MimeType.JSON);
}

function readProjects_() {
const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(PROJECTS_SHEET_NAME);
if (!sheet) throw new Error("Missing sheet: " + PROJECTS_SHEET_NAME);

const range = sheet.getDataRange();
const values = range.getDisplayValues();
const richText = range.getRichTextValues();
if (values.length < 2) return [];

const headers = values[0].map(normalizeHeader_);
const indexes = {
id: findColumn_(headers, ["project id"]),
started: findColumn_(headers, ["started"]),
title: findColumn_(headers, ["project title"]),
summary: findColumn_(headers, ["project summary"]),
leads: findColumn_(headers, ["project leads"]),
members: findColumn_(headers, ["project members"]),
partnership: findColumn_(headers, ["partnerships", "partnership"]),
technology: findColumn_(headers, ["technology"]),
theme: findColumn_(headers, ["theme"]),
result: findColumn_(headers, ["links and results"]),
media: findColumn_(headers, ["demo media", "demo video", "demo image", "media"]),
};

return values.slice(1).map(function(row, rowOffset) {
const summary = valueAt_(row, indexes.summary);
const title = valueAt_(row, indexes.title);
if (!title || !summary) return null;

return {
projectId: valueAt_(row, indexes.id),
started: valueAt_(row, indexes.started),
title: title,
summary: summary,
leads: extractLinkedPeople_(
valueAt_(row, indexes.leads),
richText[rowOffset + 1] && richText[rowOffset + 1][indexes.leads]
),
members: splitList_(valueAt_(row, indexes.members)),
partnership: normalizePartnership_(valueAt_(row, indexes.partnership)),
technologies: splitList_(valueAt_(row, indexes.technology)),
themes: splitList_(valueAt_(row, indexes.theme)),
result: valueAt_(row, indexes.result),
resultUrl: firstLink_(richText[rowOffset + 1] && richText[rowOffset + 1][indexes.result]),
mediaUrl: firstLink_(richText[rowOffset + 1] && richText[rowOffset + 1][indexes.media]) || valueAt_(row, indexes.media),
};
}).filter(Boolean);
}

function extractLinkedPeople_(text, richValue) {
const people = splitList_(text);
const linkedRuns = richValue ? richValue.getRuns().map(function(run) {
return { text: clean_(run.getText()), url: run.getLinkUrl() || "" };
}).filter(function(run) { return run.url; }) : [];

return people.map(function(name) {
const match = linkedRuns.find(function(run) {
return name.indexOf(run.text) !== -1 || run.text.indexOf(name) !== -1;
});
return { name: name, linkedin: match ? match.url : "" };
});
}

function firstLink_(richValue) {
if (!richValue) return "";
const directLink = richValue.getLinkUrl();
if (directLink) return directLink;
const linkedRun = richValue.getRuns().find(function(run) { return run.getLinkUrl(); });
return linkedRun ? linkedRun.getLinkUrl() : "";
}

function splitList_(value) {
const source = clean_(value).replace(/\s+and\s+/gi, ", ");
const items = [];
let current = "";
let depth = 0;

for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (character === "(") depth += 1;
if (character === ")") depth = Math.max(0, depth - 1);
if (character === "," && depth === 0) {
if (clean_(current)) items.push(clean_(current));
current = "";
} else {
current += character;
}
}

if (clean_(current)) items.push(clean_(current));
return items;
}

function findColumn_(headers, names) {
return headers.findIndex(function(header) {
return names.some(function(name) { return header === name || header.indexOf(name) !== -1; });
});
}

function valueAt_(row, index) {
return index >= 0 ? clean_(row[index]) : "";
}

function normalizeHeader_(value) {
return clean_(value).toLowerCase();
}

function normalizePartnership_(value) {
const normalized = clean_(value);
return /^(none|n\/a|-)?$/i.test(normalized) ? "" : normalized;
}

function clean_(value) {
return String(value || "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
}

function sanitizeCallback_(value) {
const callback = String(value || "");
return /^[A-Za-z_$][0-9A-Za-z_$]*$/.test(callback) ? callback : "";
}
16 changes: 16 additions & 0 deletions scripts/google-apps-script/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Projects Google Apps Script

This read-only web app exposes the **Projects for website** tab as JSON while preserving hyperlinks embedded in rich-text cells. The website does not read it at runtime; `npm run import-projects` uses it to update the version-controlled dataset.

1. Open the project spreadsheet.
2. Select **Extensions → Apps Script**.
3. Replace the editor contents with `Code.gs` from this folder.
4. Select **Deploy → New deployment → Web app**.
5. Set **Execute as** to **Me**.
6. Set **Who has access** to **Anyone**.
7. Deploy and copy the `/exec` URL.
8. Optionally set the URL when running the importer:

`PROJECTS_API_URL=https://script.google.com/macros/s/DEPLOYMENT_ID/exec npm run import-projects`

Add a permanent, unique **Project ID** column to the sheet. Use lowercase identifiers such as `chefost`; never change an ID after publishing a project. Run `npm run import-projects`, review `src/data/projects.json`, then commit it. Projects missing from the sheet are preserved rather than deleted.
99 changes: 99 additions & 0 deletions scripts/import-projects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env node

const fs = require("fs");
const path = require("path");

const DEFAULT_API_URL = "https://script.google.com/macros/s/AKfycbxrq50_YqA2gwj_r-CIECvsVsFZVDeYq1vBfajUJHoaCUEHufunx1qsx2ptC9F__fHv/exec";
const API_URL = String(process.env.PROJECTS_API_URL || DEFAULT_API_URL).trim();
const DATA_PATH = path.resolve(__dirname, "../src/data/projects.json");

const clean = (value) => String(value ?? "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
const slugify = (value) => clean(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
const firstUrl = (value) => clean(value).match(/https?:\/\/[^\s,]+/i)?.[0];
const normalizeList = (value) => (Array.isArray(value) ? value : []).map(clean).filter(Boolean);

const getTerm = (started) => {
const normalized = clean(started);
const year = Number(normalized.match(/\b(20\d{2})\b/)?.[1] || 0);
const lower = normalized.toLowerCase();
const season = lower.startsWith("jan") ? "Winter" : lower.startsWith("may") ? "Summer" : lower.startsWith("sep") ? "Fall" : normalized.replace(/\s*20\d{2}.*/, "");
return { term: [season, year || ""].filter(Boolean).join(" "), year };
};

const comparable = (value) => clean(value).toLowerCase();

const normalizeProject = (source) => {
const started = clean(source.started);
const title = clean(source.title);
const summary = clean(source.summary);
if (!title || !summary) throw new Error(`Every imported project needs a title and summary (received "${title || "untitled"}").`);

const { term, year } = getTerm(started);
const rawId = clean(source.projectId || source.id || "");
const id = slugify(rawId);
if (!rawId) throw new Error(`Project "${title}" is missing its permanent Project ID.`);
if (id !== rawId) throw new Error(`Project ID "${rawId}" must contain only lowercase letters, numbers, and hyphens.`);

const result = clean(source.result);
return {
id,
started,
term,
year,
title,
summary,
leads: (Array.isArray(source.leads) ? source.leads : []).map((lead) => ({
name: clean(lead?.name),
...(clean(lead?.linkedin) ? { linkedin: clean(lead.linkedin) } : {}),
})).filter((lead) => lead.name),
members: normalizeList(source.members),
...(clean(source.partnership) ? { partnership: clean(source.partnership) } : {}),
technologies: normalizeList(source.technologies),
themes: normalizeList(source.themes),
result,
...(clean(source.resultUrl) || firstUrl(result) ? { resultUrl: clean(source.resultUrl) || firstUrl(result) } : {}),
...(firstUrl(source.mediaUrl) ? { mediaUrl: firstUrl(source.mediaUrl) } : {}),
};
};

const validateUniqueIds = (projects, label) => {
const seen = new Set();
for (const project of projects) {
if (seen.has(project.id)) throw new Error(`Duplicate project ID "${project.id}" in ${label}.`);
seen.add(project.id);
}
};

async function main() {
const existing = JSON.parse(fs.readFileSync(DATA_PATH, "utf8"));
if (!Array.isArray(existing)) throw new Error("projects.json must contain an array.");
validateUniqueIds(existing, "projects.json");

const response = await fetch(API_URL);
if (!response.ok) throw new Error(`Projects API returned HTTP ${response.status}.`);
const payload = await response.json();
if (payload.status !== "ok" || !Array.isArray(payload.projects)) throw new Error("Projects API returned an invalid response.");

const incoming = payload.projects.map(normalizeProject);
validateUniqueIds(incoming, "spreadsheet import");

const representsSameProject = (left, right) => left.id === right.id || (
comparable(left.title) === comparable(right.title)
&& comparable(left.started) === comparable(right.started)
);
const merged = existing.filter((project) => !incoming.some((candidate) => representsSameProject(project, candidate)));
merged.push(...incoming);
validateUniqueIds(merged, "merged projects");
merged.sort((a, b) => (b.year || 0) - (a.year || 0) || clean(b.started).localeCompare(clean(a.started)) || a.title.localeCompare(b.title));

const temporaryPath = `${DATA_PATH}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(merged, null, 2)}\n`);
fs.renameSync(temporaryPath, DATA_PATH);
console.log(`Imported ${incoming.length} spreadsheet projects; projects.json now contains ${merged.length} projects.`);
console.log("Review the git diff before committing.");
}

main().catch((error) => {
console.error(`Project import failed: ${error.message}`);
process.exitCode = 1;
});
Loading