Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1154a58d5 | ||
|
|
727df78b53 | ||
|
|
ba656eb679 | ||
|
|
00d0db8255 | ||
|
|
2dfe6aac03 | ||
|
|
5e6b5d37d1 |
@@ -37,3 +37,105 @@ jobs:
|
|||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: gSheets-Rate-Assistant ${{ github.ref_name }}
|
name: gSheets-Rate-Assistant ${{ github.ref_name }}
|
||||||
files: dist/*.exe
|
files: dist/*.exe
|
||||||
|
|
||||||
|
- name: Mirror release to GitHub
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}
|
||||||
|
RELEASE_GITHUB_REPO: ${{ secrets.RELEASE_GITHUB_REPO }}
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
token = os.environ.get("GITHUB_TOKEN", "")
|
||||||
|
repository = os.environ.get("RELEASE_GITHUB_REPO", "")
|
||||||
|
|
||||||
|
if not token or not repository:
|
||||||
|
print("Skipping GitHub mirror because RELEASE_GITHUB_REPO or RELEASE_GITHUB_TOKEN is missing.")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
tag = os.environ["BUILD_VERSION"]
|
||||||
|
release_name = f"gSheets-Rate-Assistant {tag}"
|
||||||
|
asset_path = next((os.path.join("dist", name) for name in os.listdir("dist") if name.endswith(".exe")), None)
|
||||||
|
|
||||||
|
if asset_path is None:
|
||||||
|
raise SystemExit("No Windows executable found in dist/")
|
||||||
|
|
||||||
|
def request_json(url, method="GET", payload=None, headers=None):
|
||||||
|
request_headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
if headers:
|
||||||
|
request_headers.update(headers)
|
||||||
|
|
||||||
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
if data is not None:
|
||||||
|
request_headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
body = response.read().decode("utf-8")
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
message = error.read().decode("utf-8", errors="replace")
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
release_url = f"https://api.github.com/repos/{repository}/releases/tags/{urllib.parse.quote(tag)}"
|
||||||
|
release = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = request_json(release_url)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if error.code != 404:
|
||||||
|
raise
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": release_name,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
"generate_release_notes": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if release is None:
|
||||||
|
release = request_json(f"https://api.github.com/repos/{repository}/releases", method="POST", payload=payload)
|
||||||
|
else:
|
||||||
|
release = request_json(release["url"], method="PATCH", payload=payload)
|
||||||
|
|
||||||
|
asset_name = os.path.basename(asset_path)
|
||||||
|
for asset in release.get("assets", []):
|
||||||
|
if asset.get("name") == asset_name:
|
||||||
|
request_json(asset["url"], method="DELETE")
|
||||||
|
|
||||||
|
upload_url = release["upload_url"].split("{", 1)[0]
|
||||||
|
with open(asset_path, "rb") as asset_file:
|
||||||
|
asset_data = asset_file.read()
|
||||||
|
|
||||||
|
upload_request = urllib.request.Request(
|
||||||
|
f"{upload_url}?name={urllib.parse.quote(asset_name)}",
|
||||||
|
data=asset_data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(upload_request) as response:
|
||||||
|
response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
message = error.read().decode("utf-8", errors="replace")
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
raise
|
||||||
|
PY
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dist/
|
|||||||
# Credentials and config (do NOT commit secrets)
|
# Credentials and config (do NOT commit secrets)
|
||||||
credentials.json
|
credentials.json
|
||||||
credentials-*.json
|
credentials-*.json
|
||||||
|
serviceAccounts/
|
||||||
config.json
|
config.json
|
||||||
config-*.json
|
config-*.json
|
||||||
.env
|
.env
|
||||||
|
|||||||
+12
-1
@@ -128,6 +128,15 @@ function loadConfig() {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getConfigBaseDir(config) {
|
||||||
|
const configPath = config && (config.__configPath || config.configPath);
|
||||||
|
if (configPath) {
|
||||||
|
return path.dirname(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
function resolveOutputPath(outputDir, filename) {
|
function resolveOutputPath(outputDir, filename) {
|
||||||
if (!path.isAbsolute(outputDir)) {
|
if (!path.isAbsolute(outputDir)) {
|
||||||
outputDir = path.resolve(process.cwd(), outputDir);
|
outputDir = path.resolve(process.cwd(), outputDir);
|
||||||
@@ -203,6 +212,8 @@ function validateConfig(config) {
|
|||||||
|
|
||||||
// Ensure service account credential paths are unique when using serviceAccounts
|
// Ensure service account credential paths are unique when using serviceAccounts
|
||||||
if (hasSvcAccounts) {
|
if (hasSvcAccounts) {
|
||||||
|
const baseDir = getConfigBaseDir(config);
|
||||||
|
|
||||||
for (const item of config.serviceAccounts) {
|
for (const item of config.serviceAccounts) {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
if (!item.trim()) {
|
if (!item.trim()) {
|
||||||
@@ -231,7 +242,7 @@ function validateConfig(config) {
|
|||||||
if (typeof item === 'string') p = item.trim();
|
if (typeof item === 'string') p = item.trim();
|
||||||
else if (item && typeof item === 'object') p = (item.path || item.file || item.credentialsPath || '').toString().trim();
|
else if (item && typeof item === 'object') p = (item.path || item.file || item.credentialsPath || '').toString().trim();
|
||||||
if (!p) return '';
|
if (!p) return '';
|
||||||
const full = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
const full = path.isAbsolute(p) ? p : path.resolve(baseDir, p);
|
||||||
if (!fs.existsSync(full)) {
|
if (!fs.existsSync(full)) {
|
||||||
throw new Error(`Service account file not found: ${p}`);
|
throw new Error(`Service account file not found: ${p}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-10
@@ -1,3 +1,5 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config');
|
const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config');
|
||||||
const {getFirstServiceAccountAuthClient} = require('./auth');
|
const {getFirstServiceAccountAuthClient} = require('./auth');
|
||||||
@@ -78,6 +80,79 @@ async function askOptionalPositiveInteger(ask, question, defaultValue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getServiceAccountIdentity(inputPath, baseDir) {
|
||||||
|
const value = (inputPath || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.isAbsolute(value) ? value : path.resolve(baseDir || process.cwd(), value);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(fullPath, 'utf8').trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const canonicalize = obj => {
|
||||||
|
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
||||||
|
if (Array.isArray(obj)) return '[' + obj.map(canonicalize).join(',') + ']';
|
||||||
|
const keys = Object.keys(obj).sort();
|
||||||
|
return '{' + keys.map(key => JSON.stringify(key) + ':' + canonicalize(obj[key])).join(',') + '}';
|
||||||
|
};
|
||||||
|
return canonicalize(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceAccountsFolderPath(configPath) {
|
||||||
|
return path.join(path.dirname(configPath), 'serviceAccounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listServiceAccountFiles(folderPath) {
|
||||||
|
if (!fs.existsSync(folderPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readdirSync(folderPath)
|
||||||
|
.map(fileName => path.join(folderPath, fileName))
|
||||||
|
.filter(filePath => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).isFile();
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSpreadsheetId(input) {
|
||||||
|
const value = (input || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlMatch = value.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/i);
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
return urlMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askSpreadsheetIdOrUrl(ask, question) {
|
||||||
|
while (true) {
|
||||||
|
const answer = await ask(question);
|
||||||
|
const documentId = extractSpreadsheetId(answer);
|
||||||
|
|
||||||
|
if (documentId) {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Please enter a Google Sheets URL or spreadsheet ID.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pauseForEnter(ask) {
|
async function pauseForEnter(ask) {
|
||||||
await ask('Press Enter to close this window...');
|
await ask('Press Enter to close this window...');
|
||||||
}
|
}
|
||||||
@@ -132,10 +207,10 @@ async function runOnboarding() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const credentialCount = await askPositiveInteger(
|
if (authMode === 'apiKeys') {
|
||||||
ask,
|
console.log('Warning: when using API keys, the spreadsheet must be publicly accessible.');
|
||||||
`How many ${authMode === 'apiKeys' ? 'API keys' : 'service accounts'} do you want to add? `
|
console.log('');
|
||||||
);
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
apiKeys: [],
|
apiKeys: [],
|
||||||
@@ -150,33 +225,79 @@ async function runOnboarding() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (authMode === 'apiKeys') {
|
if (authMode === 'apiKeys') {
|
||||||
|
const credentialCount = await askPositiveInteger(
|
||||||
|
ask,
|
||||||
|
'How many API keys do you want to add? '
|
||||||
|
);
|
||||||
|
|
||||||
|
const seenKeys = new Set();
|
||||||
|
|
||||||
for (let index = 0; index < credentialCount; index += 1) {
|
for (let index = 0; index < credentialCount; index += 1) {
|
||||||
const key = await askNonEmpty(ask, `Enter API key ${index + 1}: `);
|
let key;
|
||||||
|
while (true) {
|
||||||
|
key = await askNonEmpty(ask, `Enter API key ${index + 1}: `);
|
||||||
|
if (seenKeys.has(key)) {
|
||||||
|
console.log('That API key was already entered. Please enter a unique API key.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const rateLimitPerMinute = await askOptionalPositiveInteger(
|
const rateLimitPerMinute = await askOptionalPositiveInteger(
|
||||||
ask,
|
ask,
|
||||||
`Rate limit per minute for API key ${index + 1} (press Enter for 50): `,
|
`Rate limit per minute for API key ${index + 1} (press Enter for 50): `,
|
||||||
50
|
50
|
||||||
);
|
);
|
||||||
|
|
||||||
|
seenKeys.add(key);
|
||||||
config.apiKeys.push({key, rateLimitPerMinute});
|
config.apiKeys.push({key, rateLimitPerMinute});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let index = 0; index < credentialCount; index += 1) {
|
const serviceAccountsFolder = getServiceAccountsFolderPath(configPath);
|
||||||
const pathValue = await askNonEmpty(ask, `Enter service account path ${index + 1}: `);
|
if (!fs.existsSync(serviceAccountsFolder)) {
|
||||||
|
fs.mkdirSync(serviceAccountsFolder, {recursive: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`A serviceAccounts folder has been created at ${serviceAccountsFolder}.`);
|
||||||
|
console.log('Paste your service account files into that folder, then press Enter to continue.');
|
||||||
|
await pauseForEnter(ask);
|
||||||
|
|
||||||
|
const serviceAccountFiles = listServiceAccountFiles(serviceAccountsFolder);
|
||||||
|
console.log(`Found ${serviceAccountFiles.length} service account${serviceAccountFiles.length === 1 ? '' : 's'}.`);
|
||||||
|
|
||||||
|
if (serviceAccountFiles.length === 0) {
|
||||||
|
throw new Error(`No service account files were found in ${serviceAccountsFolder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenServiceAccounts = new Set();
|
||||||
|
|
||||||
|
for (const filePath of serviceAccountFiles) {
|
||||||
|
const relativePath = path.relative(path.dirname(configPath), filePath).split(path.sep).join('/');
|
||||||
|
const serviceAccountIdentity = getServiceAccountIdentity(relativePath, path.dirname(configPath));
|
||||||
|
|
||||||
|
if (seenServiceAccounts.has(serviceAccountIdentity)) {
|
||||||
|
console.log(`Skipping duplicate service account file: ${path.basename(filePath)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenServiceAccounts.add(serviceAccountIdentity);
|
||||||
const rateLimitPerMinute = await askOptionalPositiveInteger(
|
const rateLimitPerMinute = await askOptionalPositiveInteger(
|
||||||
ask,
|
ask,
|
||||||
`Rate limit per minute for service account ${index + 1} (press Enter for 50): `,
|
`Rate limit per minute for ${path.basename(filePath)} (press Enter for 50): `,
|
||||||
50
|
50
|
||||||
);
|
);
|
||||||
|
|
||||||
config.serviceAccounts.push({path: pathValue, rateLimitPerMinute});
|
config.serviceAccounts.push({path: relativePath, rateLimitPerMinute});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentCount = await askPositiveInteger(ask, 'How many documents do you want to pull from? ');
|
const documentCount = await askPositiveInteger(ask, 'How many documents do you want to pull from? ');
|
||||||
|
|
||||||
for (let documentIndex = 0; documentIndex < documentCount; documentIndex += 1) {
|
for (let documentIndex = 0; documentIndex < documentCount; documentIndex += 1) {
|
||||||
const documentId = await askNonEmpty(ask, `Enter document ID ${documentIndex + 1}: `);
|
const documentId = await askSpreadsheetIdOrUrl(
|
||||||
|
ask,
|
||||||
|
`Enter Google Sheets URL or document ID ${documentIndex + 1}: `
|
||||||
|
);
|
||||||
let documentTitle = documentId;
|
let documentTitle = documentId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "google-sheets-rate-assistant",
|
"name": "google-sheets-rate-assistant",
|
||||||
"version": "2.1.3",
|
"version": "2.1.8",
|
||||||
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
|
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"bin": "index.js",
|
"bin": "index.js",
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ To build a Windows executable from source, run:
|
|||||||
npm run build:win
|
npm run build:win
|
||||||
```
|
```
|
||||||
|
|
||||||
That creates `dist/gSheets-Rate-Assistant-v2.0.0.exe` using the current version in the filename. For distribution, ship the exe alongside a `config.json` and any service account credential files, or set `GSA_CONFIG` to point at a different config file.
|
That creates `dist/gSheets-Rate-Assistant-v2.1.8.exe` using the current version in the filename. For distribution, ship the exe alongside a `config.json` and any service account credential files, or set `GSA_CONFIG` to point at a different config file.
|
||||||
|
|
||||||
Gitea now builds only on version tags like `v1.1.1` through the workflow in `.gitea/workflows/build.yml`.
|
Gitea now builds only on version tags like `v1.1.1` through the workflow in `.gitea/workflows/build.yml`.
|
||||||
|
If you also want the same tag to create a GitHub release, add `RELEASE_GITHUB_TOKEN` and `RELEASE_GITHUB_REPO` as secrets in Gitea so the workflow can mirror the release and upload the Windows asset to GitHub.
|
||||||
|
|
||||||
## AI Disclosure
|
## AI Disclosure
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user