diff --git a/package.json b/package.json index 286cddb..57570f1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "packages/br-cal", "packages/br-script", "packages/discover-dl", - "packages/lm-sync" + "packages/lm-sync", + "packages/makesheet" ], "devDependencies": { "@endo/eslint-plugin": "^0.5.1", diff --git a/packages/makesheet/.claspignore b/packages/makesheet/.claspignore new file mode 100644 index 0000000..c1278dd --- /dev/null +++ b/packages/makesheet/.claspignore @@ -0,0 +1,2 @@ +.git/** +node_modules/** diff --git a/packages/makesheet/.eslintrc.json b/packages/makesheet/.eslintrc.json new file mode 100644 index 0000000..cec9acb --- /dev/null +++ b/packages/makesheet/.eslintrc.json @@ -0,0 +1,37 @@ +{ + "extends": [ + "airbnb-base" + ], + "env": { + "es6": true + }, + "rules": { + "quotes": [ + "error", + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": true + } + ], + "comma-dangle": ["error", "always-multiline"], + "no-console": "off", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "no-undef": "off", + "no-underscore-dangle": [ + "error", + { + "allowAfterThis": false, + "allowAfterSuper": false, + "enforceInMethodNames": false, + "allow": ["_testDoGet", "_testCreateJsonResponse"] + } + ] + } +} diff --git a/packages/makesheet/.gitignore b/packages/makesheet/.gitignore new file mode 100644 index 0000000..d8b83df --- /dev/null +++ b/packages/makesheet/.gitignore @@ -0,0 +1 @@ +package-lock.json diff --git a/packages/makesheet/CONTRIBUTING.md b/packages/makesheet/CONTRIBUTING.md new file mode 100644 index 0000000..031b354 --- /dev/null +++ b/packages/makesheet/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to makesheet + +## Code Style + +This project follows Airbnb JavaScript style guidelines. Use ESLint to check and fix code style: + +```bash +yarn lint +yarn lint-fix +``` + +## Object Capability (OCap) Discipline + +This project follows object-capability discipline to enable secure composition and testing. This means avoiding ambient authority where possible. + +### Ambient Authority Pattern + +When functions need to access global APIs (like `DriveApp.getFileById`), use dependency injection to allow callers to provide these capabilities: + +```js +function doGet(e, io = {}) { + const { + getFileById = DriveApp.getFileById, + } = io; + // ... use getFileById instead of DriveApp.getFileById directly +} +``` + +This pattern provides several benefits: + +1. **Testability**: Callers can inject mock implementations for testing +2. **Attenuation**: Callers can provide restricted versions of capabilities +3. **Composability**: Functions become more modular and reusable +4. **Security**: Reduces ambient authority and enables principle of least privilege + +### References + +For more details on object-capability discipline, see the [Agoric OCap Discipline wiki](https://github.com/Agoric/agoric-sdk/wiki/OCap-Discipline). + +## Testing + +Since this is Google Apps Script code, we use test functions that can be executed in the Google Apps Script debugger: + +1. Create test functions prefixed with underscore (e.g., `_testDoGet`) +2. Use `console.log` to output test results +3. Run tests by selecting the test function in the GAS editor and clicking run +4. View results in the console output + +This approach allows testing without external frameworks while maintaining the ability to test core functionality. \ No newline at end of file diff --git a/packages/makesheet/README.md b/packages/makesheet/README.md new file mode 100644 index 0000000..23da340 --- /dev/null +++ b/packages/makesheet/README.md @@ -0,0 +1,91 @@ +# makesheet + +Google Sheets timestamp service for Make-based workflows. + +## Overview + +This package provides a Google Apps Script web app that returns the modification timestamp of a Google Sheet. This is useful for Make-based build systems that need to track when a sheet has been modified to trigger downstream processing. + +## Setup + +1. Install clasp globally (required for deployment, though you could add it as a devDependency if preferred): + ```bash + npm install -g @google/clasp + ``` + +2. Login to Google Apps Script: + ```bash + yarn login + ``` + +3. Create a new Google Apps Script project and note the script ID. + [See the Google Apps Script documentation](https://developers.google.com/apps-script/guides/projects) for detailed instructions on creating projects. + +4. Update `.clasp.json` with your script ID: + ```json + { + "scriptId": "your-script-id-here", + "rootDir": "." + } + ``` + +5. Push the code to Google Apps Script: + ```bash + yarn push + ``` + +6. Deploy as a web app and note the deployment ID: + ```bash + yarn deploy + ``` + [See the clasp deployment documentation](https://developers.google.com/apps-script/guides/clasp#deployments) for more details on deployment options and web app configuration. + +## Usage + +Once deployed, you can query the modification time of a Google Sheet using: + +```bash +curl -s "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec?sheetId=YOUR_SHEET_ID" +``` + +This returns a JSON response: +```json +{ + "modifiedTime": "2023-12-01T15:30:45.123Z" +} +``` + +Or an error response: +```json +{ + "error": "sheetId parameter required" +} +``` + +## Integration with Make + +Use the provided `example-usage.mk` file as a reference for integrating with Make-based workflows. + +## Scripts + +- `yarn push` - Push code to Google Apps Script (uploads your local files to the remote project) +- `yarn deploy` - Push and deploy as web app (requires DEPLOYMENT_ID env var) (creates a new web app version that can be accessed via URL) +- `yarn curl` - Test the deployed web app (requires DEPLOYMENT_ID and SHEET_ID env vars) +- `yarn login` - Login to Google Apps Script +- `yarn lint` - Run ESLint to check code style (Airbnb style) +- `yarn lint-fix` - Run ESLint and automatically fix issues + +## Testing + +The code includes test functions that can be used with the Google Apps Script debugger: + +- `_testDoGet()` - Tests the main `doGet` function with various input scenarios +- `_testCreateJsonResponse()` - Tests the JSON response creation function + +To run tests in the Google Apps Script editor: +1. Open your script in the Google Apps Script editor +2. Select one of the test functions from the function dropdown +3. Click the run button to execute the test +4. View the output in the console + +This approach follows the pattern suggested for testing Google Apps Script functions without requiring external test frameworks. \ No newline at end of file diff --git a/packages/makesheet/appsscript.json b/packages/makesheet/appsscript.json new file mode 100644 index 0000000..b8a8e97 --- /dev/null +++ b/packages/makesheet/appsscript.json @@ -0,0 +1,11 @@ +{ + "webapp": { + "access": "ANYONE_ANONYMOUS", + "executeAs": "USER_DEPLOYING" + }, + "timeZone": "America/Chicago", + "dependencies": { + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} diff --git a/packages/makesheet/example-usage.mk b/packages/makesheet/example-usage.mk new file mode 100644 index 0000000..dd34374 --- /dev/null +++ b/packages/makesheet/example-usage.mk @@ -0,0 +1,46 @@ +# Example usage of makesheet package in Makefile workflows +# +# Set these environment variables: +# DEPLOYMENT_ID = your Google Apps Script deployment ID +# SHEET_ID = the Google Sheet ID you want to track + +# Target file to store the sheet's timestamp +SHEET_TIMESTAMP_FILE = sheet.timestamp + +# URL for the deployed makesheet web app +SHEET_INFO_URL = https://script.google.com/macros/s/$(DEPLOYMENT_ID)/exec + +# Create a timestamp file based on the sheet's last modification time +$(SHEET_TIMESTAMP_FILE): + curl -s "$(SHEET_INFO_URL)?sheetId=$(SHEET_ID)" | \ + jq -r '.modifiedTime' | \ + xargs -I {} touch -d {} $@ + +# Example target that depends on the sheet timestamp +data-processing: $(SHEET_TIMESTAMP_FILE) + @echo "Processing data from sheet (last modified: $$(cat $(SHEET_TIMESTAMP_FILE)))" + # Your data processing commands here + +# Clean up timestamp file +clean: + rm -f $(SHEET_TIMESTAMP_FILE) + +# Check if sheet has been modified since last processing +check-sheet-status: $(SHEET_TIMESTAMP_FILE) + @if [ -f $(SHEET_TIMESTAMP_FILE) ]; then \ + echo "Sheet timestamp file exists: $$(ls -la $(SHEET_TIMESTAMP_FILE))"; \ + else \ + echo "Sheet timestamp file does not exist, will be created"; \ + fi + +# Alternative approach: always check and update if sheet is newer +update-if-newer: + @CURRENT_TIME=$$(curl -s "$(SHEET_INFO_URL)?sheetId=$(SHEET_ID)" | jq -r '.modifiedTime'); \ + if [ ! -f $(SHEET_TIMESTAMP_FILE) ] || [ "$$(date -r $(SHEET_TIMESTAMP_FILE) -u +%Y-%m-%dT%H:%M:%S.%3NZ)" != "$$CURRENT_TIME" ]; then \ + echo "Sheet has been modified, updating timestamp"; \ + echo "$$CURRENT_TIME" | xargs -I {} touch -d {} $(SHEET_TIMESTAMP_FILE); \ + else \ + echo "Sheet has not been modified since last check"; \ + fi + +.PHONY: data-processing clean check-sheet-status update-if-newer diff --git a/packages/makesheet/package.json b/packages/makesheet/package.json new file mode 100644 index 0000000..e1add2b --- /dev/null +++ b/packages/makesheet/package.json @@ -0,0 +1,23 @@ +{ + "name": "makesheet", + "version": "0.1.0", + "description": "Google Sheets timestamp service for Make-based workflows", + "author": "Dan Connolly (with GitHub Copilot)", + "license": "MIT", + "scripts": { + "push": "clasp push", + "deploy": "clasp push; clasp deploy --deploymentId $DEPLOYMENT_ID", + "curl": "curl -L https://script.google.com/macros/s/$DEPLOYMENT_ID/exec?sheetId=$SHEET_ID", + "login": "clasp login", + "lint": "eslint --config .eslintrc.json --no-eslintrc *.js", + "lint-fix": "eslint --config .eslintrc.json --no-eslintrc --fix *.js" + }, + "devDependencies": { + "@google/clasp": "^2.3.0", + "@types/google-apps-script": "^1.0.17", + "typescript": "^4.0.5", + "eslint": "^7.32.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-plugin-import": "^2.22.1" + } +} diff --git a/packages/makesheet/sheetInfo.js b/packages/makesheet/sheetInfo.js new file mode 100644 index 0000000..4354aca --- /dev/null +++ b/packages/makesheet/sheetInfo.js @@ -0,0 +1,77 @@ +/** + * Creates a JSON response for the web app + * @param {Object} data - The data to include in the JSON response + * @returns {GoogleAppsScript.Content.TextOutput} The JSON response + */ +function createJsonResponse(data) { + return ContentService.createTextOutput(JSON.stringify(data)).setMimeType( + ContentService.MimeType.JSON, + ); +} + +/** + * Main web app handler that returns sheet modification timestamps + * @param {GoogleAppsScript.Events.DoGet} e - The GET request event + * @param {Object} io - Dependency injection object for testing + * @param {Function} io.getFileById - Function to get file by ID (defaults to DriveApp.getFileById) + * @returns {GoogleAppsScript.Content.TextOutput} JSON response with timestamp or error + */ +function doGet(e, io = {}) { + const { + getFileById = DriveApp.getFileById, + } = io; + + const { sheetId } = e.parameter; + if (!sheetId) { + return createJsonResponse({ error: 'sheetId parameter required' }); + } + + try { + const file = getFileById(sheetId); + const modifiedTime = file.getLastUpdated().toISOString(); + return createJsonResponse({ modifiedTime }); + } catch (error) { + return createJsonResponse({ error: error.toString() }); + } +} + +// Test functions for use with Google Apps Script debugger +/** + * Test function for doGet with various scenarios + */ +function _testDoGet() { + console.log('Testing doGet with missing sheetId...'); + const resultMissing = doGet({ parameter: {} }); + console.log('Result:', resultMissing.getContent()); + + console.log('Testing doGet with invalid sheetId...'); + const resultInvalid = doGet({ parameter: { sheetId: 'invalid-id' } }); + console.log('Result:', resultInvalid.getContent()); + + console.log('Testing doGet with mock getFileById...'); + const mockFile = { + getLastUpdated: () => new Date('2023-12-01T15:30:45.123Z'), + }; + const mockIo = { + getFileById: () => mockFile, + }; + const resultMock = doGet({ parameter: { sheetId: 'test-id' } }, mockIo); + console.log('Result:', resultMock.getContent()); + + // Note: To test with a valid sheet ID, replace 'your-test-sheet-id' + // with an actual Google Sheets ID you have access to + console.log('Testing doGet with valid sheetId (uncomment to test)...'); + // const resultValid = doGet({ parameter: { sheetId: 'your-test-sheet-id' } }); + // console.log('Result:', resultValid.getContent()); +} + +/** + * Test function for createJsonResponse + */ +function _testCreateJsonResponse() { + console.log('Testing createJsonResponse...'); + const testData = { test: 'value', timestamp: new Date().toISOString() }; + const response = createJsonResponse(testData); + console.log('Response content:', response.getContent()); + console.log('Response MIME type:', response.getMimeType()); +}