Compare commits
9 Commits
17e0c1b707
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 237f57cf36 | |||
| a893cd326f | |||
| 92f636f247 | |||
| 66c711916e | |||
| 5b6db0eece | |||
| a5f9b1542c | |||
| 688536d0ac | |||
| 6d75973835 | |||
| 62514fa20e |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
**/node_modules/**
|
||||
dist/**
|
||||
desktop.ini
|
||||
.continue/**
|
||||
@ -43,37 +43,24 @@ export function columnOnEditHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
|
||||
for (let row = e.range.getRow(); row <= e.range.getLastRow(); row++) {
|
||||
console.log("row: " + row)
|
||||
let updateString = "updating " + header + " on row " + row
|
||||
switch (header) {
|
||||
case "shopify_status":
|
||||
let shopifyUpdateColumns = [
|
||||
"shopify_status",
|
||||
"title",
|
||||
"product_type",
|
||||
"tags",
|
||||
"description",
|
||||
"base_price",
|
||||
"original_price",
|
||||
"weight_grams",
|
||||
"product_height_cm",
|
||||
"product_width_cm",
|
||||
"product_depth_cm"
|
||||
]
|
||||
if (shopifyUpdateColumns.includes(header)) {
|
||||
// Accumulate changes for 30s before updating
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
case "title":
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
case "product_type":
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
case "tags":
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
case "description":
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
case "price":
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
case "compare_at_price":
|
||||
toastAndLog(updateString)
|
||||
updateProductToShopify(row)
|
||||
break
|
||||
default:
|
||||
continue
|
||||
}
|
||||
toastAndLog("completed " + updateString)
|
||||
}
|
||||
|
||||
135
src/Product.ts
135
src/Product.ts
@ -11,17 +11,21 @@ import {
|
||||
VariantOptionValueInput,
|
||||
formatGqlForJSON,
|
||||
} from "./shopifyApi"
|
||||
import * as shopify from 'shopify-admin-api-typings'
|
||||
import { getCellRangeByColumnName, getRowByColumnValue } from "./sheetUtils"
|
||||
import * as shopify from "shopify-admin-api-typings"
|
||||
import {
|
||||
getCellRangeByColumnName,
|
||||
getRowByColumnValue,
|
||||
vlookupByColumns,
|
||||
} from "./sheetUtils"
|
||||
import { Config } from "./config"
|
||||
|
||||
|
||||
export class Product {
|
||||
shopify_id: string = ""
|
||||
title: string = ""
|
||||
style: string[] = []
|
||||
tags: string = ""
|
||||
category: string = ""
|
||||
ebay_category_id: string = ""
|
||||
product_type: string = ""
|
||||
description: string = ""
|
||||
sku: string = ""
|
||||
@ -114,10 +118,26 @@ export class Product {
|
||||
)
|
||||
}
|
||||
|
||||
ShopifyCategory(): string {
|
||||
return vlookupByColumns(
|
||||
"values",
|
||||
"product_type",
|
||||
this.product_type,
|
||||
"shopify_category"
|
||||
)
|
||||
}
|
||||
|
||||
EbayCategory(): string {
|
||||
return vlookupByColumns(
|
||||
"values",
|
||||
"product_type",
|
||||
this.product_type,
|
||||
"ebay_category_id"
|
||||
)
|
||||
}
|
||||
|
||||
ToShopifyProductSet() {
|
||||
let sps = new ShopifyProductSetInput()
|
||||
//TODO: map category IDs
|
||||
//sps.category = this.category
|
||||
if (this.shopify_id != "") {
|
||||
sps.id = this.shopify_id
|
||||
}
|
||||
@ -125,6 +145,10 @@ export class Product {
|
||||
sps.status = this.shopify_status
|
||||
}
|
||||
sps.productType = this.product_type
|
||||
let category = this.ShopifyCategory()
|
||||
if (category !== "") {
|
||||
sps.category = this.ShopifyCategory()
|
||||
}
|
||||
sps.tags = this.tags
|
||||
sps.title = this.title
|
||||
sps.descriptionHtml = this.description
|
||||
@ -180,7 +204,12 @@ export class Product {
|
||||
shop.SetInventoryItemDefaults(item, config)
|
||||
if (this.weight_grams > 0) {
|
||||
console.log("UpdateShopifyProduct: setting weight on inventory item")
|
||||
shop.SetInventoryItemWeight(item, config, this.weight_grams, shopify.WeightUnit.GRAMS)
|
||||
shop.SetInventoryItemWeight(
|
||||
item,
|
||||
config,
|
||||
this.weight_grams,
|
||||
shopify.WeightUnit.GRAMS
|
||||
)
|
||||
}
|
||||
if (newProduct) {
|
||||
console.log("UpdateShopifyProduct: setting defaults on new product")
|
||||
@ -188,13 +217,14 @@ export class Product {
|
||||
shop.UpdateInventoryItemQuantity(item, 1, config)
|
||||
console.log(JSON.stringify(response, null, 2))
|
||||
}
|
||||
// update dimension metafields
|
||||
this.UpdateDimensionMetafields(shop)
|
||||
// update all metafields
|
||||
this.UpdateAllMetafields(shop);
|
||||
// create product photo folder
|
||||
this.CreatePhotoFolder();
|
||||
}
|
||||
|
||||
// TODO: Make this a Product class method?
|
||||
UpdateDimensionMetafields(shop: Shop) {
|
||||
console.log("UpdateDimensionMetafields()")
|
||||
UpdateAllMetafields(shop: Shop) {
|
||||
console.log("UpdateAllMetafields()")
|
||||
if (!this.shopify_id) {
|
||||
console.log("Cannot update metafields without a Shopify Product ID.")
|
||||
return
|
||||
@ -202,6 +232,27 @@ export class Product {
|
||||
|
||||
const metafieldsToSet: shopify.MetafieldsSetInput[] = []
|
||||
|
||||
// eBay Category Metafield
|
||||
if (this.product_type) {
|
||||
this.ebay_category_id = this.EbayCategory()
|
||||
if (this.ebay_category_id) {
|
||||
metafieldsToSet.push({
|
||||
key: "ebay_category_id",
|
||||
namespace: "custom",
|
||||
ownerId: this.shopify_id,
|
||||
type: "single_line_text_field",
|
||||
value: this.ebay_category_id.toString(),
|
||||
})
|
||||
} else {
|
||||
console.log(
|
||||
`No eBay category defined for product type '${this.product_type}'`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.log("No product type set, skipping eBay category metafield.")
|
||||
}
|
||||
|
||||
// Dimension Metafields
|
||||
if (this.product_height_cm > 0) {
|
||||
metafieldsToSet.push({
|
||||
key: "product_height_cm",
|
||||
@ -242,7 +293,7 @@ export class Product {
|
||||
}
|
||||
|
||||
if (metafieldsToSet.length === 0) {
|
||||
console.log("No dimension metafields to update.")
|
||||
console.log("No metafields to update.")
|
||||
return
|
||||
}
|
||||
|
||||
@ -273,11 +324,16 @@ export class Product {
|
||||
"variables": ${JSON.stringify(variables)}
|
||||
}`
|
||||
|
||||
console.log("Setting dimension metafields with query:\n" + json)
|
||||
console.log("Setting metafields with query:\n" + json)
|
||||
const response = shop.shopifyGraphQLAPI(JSON.parse(json))
|
||||
console.log("metafieldsSet response: " + JSON.stringify(response, null, 2))
|
||||
}
|
||||
|
||||
CreatePhotoFolder() {
|
||||
console.log("Product.CreatePhotoFolder()");
|
||||
createPhotoFolderForSku(new(Config), this.sku);
|
||||
}
|
||||
|
||||
PublishToShopifyOnlineStore(shop: Shop) {
|
||||
console.log("PublishToShopifyOnlineStore")
|
||||
let config = new Config()
|
||||
@ -322,3 +378,56 @@ export class Product {
|
||||
// TODO: shopify_status
|
||||
}
|
||||
}
|
||||
|
||||
export function createPhotoFolderForSku(config: Config, sku: string) {
|
||||
console.log(`createPhotoFolderForSku('${sku}')`)
|
||||
if (!config.productPhotosFolderId) {
|
||||
console.log(
|
||||
"productPhotoFolderId not set in config. Skipping folder creation."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const productInventorySheet =
|
||||
SpreadsheetApp.getActiveSpreadsheet().getSheetByName("product_inventory")
|
||||
const row = getRowByColumnValue("product_inventory", "sku", sku)
|
||||
if (!row) {
|
||||
console.log(`SKU '${sku}' not found in sheet. Cannot create folder.`)
|
||||
return
|
||||
}
|
||||
const photosCell = getCellRangeByColumnName(
|
||||
productInventorySheet,
|
||||
"photos",
|
||||
row
|
||||
)
|
||||
const folderUrl = photosCell.getRichTextValue().getLinkUrl()
|
||||
console.log(`Folder URL from cell: ${folderUrl}`)
|
||||
|
||||
if (folderUrl && folderUrl.includes("drive.google.com")) {
|
||||
console.log(`Photo folder already exists: ${folderUrl}`)
|
||||
return
|
||||
} else {
|
||||
console.log(`Creating photo folder for SKU: ${sku}`)
|
||||
}
|
||||
|
||||
const parentFolder = DriveApp.getFolderById(config.productPhotosFolderId)
|
||||
const folderName = sku
|
||||
let newFolder: GoogleAppsScript.Drive.Folder
|
||||
|
||||
const existingFolders = parentFolder.getFoldersByName(folderName)
|
||||
if (existingFolders.hasNext()) {
|
||||
newFolder = existingFolders.next()
|
||||
console.log(`Found existing photo folder: '${folderName}'`)
|
||||
} else {
|
||||
newFolder = parentFolder.createFolder(folderName)
|
||||
console.log(`Created new photo folder: '${folderName}'`)
|
||||
}
|
||||
let url = newFolder.getUrl()
|
||||
console.log(`Folder URL: ${url}`)
|
||||
|
||||
let linkValue = SpreadsheetApp.newRichTextValue()
|
||||
.setText(folderName)
|
||||
.setLinkUrl(url)
|
||||
.build()
|
||||
photosCell.setRichTextValue(linkValue)
|
||||
}
|
||||
|
||||
@ -1,56 +1,32 @@
|
||||
import { createPhotoFolderForSku } from "./Product"
|
||||
import { getColumnRichTextByName, getColumnValuesByName, toastAndLog } from "./sheetUtils"
|
||||
import { Config } from "./config"
|
||||
import {
|
||||
getCellRangeByColumnName,
|
||||
getColumnValuesByName,
|
||||
toastAndLog,
|
||||
} from "./sheetUtils"
|
||||
|
||||
export function createMissingPhotoFolders() {
|
||||
let ss = SpreadsheetApp.getActive()
|
||||
let s = ss.getSheetByName("product_inventory")
|
||||
let config = new Config()
|
||||
let photoParent = DriveApp.getFolderById(config.productPhotosFolderId)
|
||||
const ss = SpreadsheetApp.getActive()
|
||||
const s = ss.getSheetByName("product_inventory")
|
||||
if (!s) {
|
||||
toastAndLog("Could not find 'product_inventory' sheet.")
|
||||
return
|
||||
}
|
||||
|
||||
let skus = getColumnValuesByName(s, "sku")
|
||||
let photoLinks = getColumnValuesByName(s, "photos")
|
||||
let created: string[] = []
|
||||
let photos = getColumnRichTextByName(s, "photos")
|
||||
let config = new Config()
|
||||
|
||||
let folderItr = photoParent.getFolders()
|
||||
let folderNames: string[] = []
|
||||
console.log("getting list of existing folders...")
|
||||
while (folderItr.hasNext()) {
|
||||
let folder = folderItr.next()
|
||||
folderNames.push(folder.getName())
|
||||
}
|
||||
console.log("existing folders: " + folderNames.join(", "))
|
||||
|
||||
for (let i = 0; i < skus.length; i++) {
|
||||
let sku = String(skus[i][0])
|
||||
let updateLink: boolean = false
|
||||
if (null == sku || sku == "") {
|
||||
// Process rows backward, as that is where the missing folders are most likely to occur
|
||||
for (let i = skus.length - 1; i >= 0; i--) {
|
||||
const sku = String(skus[i][0])
|
||||
if (!sku) {
|
||||
continue
|
||||
}
|
||||
if (folderNames.includes(sku)) {
|
||||
console.log("folder '" + sku + "' already exists")
|
||||
} else {
|
||||
console.log("creating folder '" + skus[i] + "'")
|
||||
photoParent.createFolder(sku)
|
||||
created.push(sku)
|
||||
updateLink = true
|
||||
}
|
||||
|
||||
// Update photos cell
|
||||
if (photoLinks[i][0] != "" && !updateLink) {
|
||||
let folderUrl = photos[i][0].getLinkUrl()
|
||||
if (folderUrl && folderUrl.includes("drive.google.com")) {
|
||||
console.log(`Photo folder already exists for SKU: ${sku}`)
|
||||
continue
|
||||
}
|
||||
console.log("updating photos cell for '" + sku + "'")
|
||||
let photosCell = getCellRangeByColumnName(s, "photos", i + 2)
|
||||
let folder = photoParent.getFoldersByName(sku).next()
|
||||
let url = folder.getUrl()
|
||||
let linkValue = SpreadsheetApp.newRichTextValue()
|
||||
.setText(sku)
|
||||
.setLinkUrl(url)
|
||||
.build()
|
||||
photosCell.setRichTextValue(linkValue)
|
||||
|
||||
createPhotoFolderForSku(config, sku)
|
||||
}
|
||||
toastAndLog("created " + created.length + " folders: " + created.join(", "))
|
||||
toastAndLog("Finished creating missing photo folders.")
|
||||
}
|
||||
|
||||
@ -14,6 +14,10 @@ import { createMissingPhotoFolders } from "./createMissingPhotoFolders"
|
||||
import { reinstallTriggers } from "./triggers"
|
||||
import { newSkuHandler } from "./newSku"
|
||||
import { columnOnEditHandler } from "./OnEditHandler"
|
||||
import {
|
||||
onEditQueue,
|
||||
processBatchedEdits
|
||||
} from "./onEditQueue"
|
||||
import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
||||
|
||||
// prettier-ignore
|
||||
@ -25,6 +29,8 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
||||
;(global as any).matchProductToShopifyOnEditHandler = matchProductToShopifyOnEditHandler
|
||||
;(global as any).updateShopifyProductHandler = updateShopifyProductHandler
|
||||
;(global as any).columnOnEditHandler = columnOnEditHandler
|
||||
;(global as any).onEditQueue = onEditQueue
|
||||
;(global as any).processBatchedEdits = processBatchedEdits
|
||||
;(global as any).reauthorizeScript = reauthorizeScript
|
||||
;(global as any).reinstallTriggers = reinstallTriggers
|
||||
;(global as any).newSkuHandler = newSkuHandler
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { createPhotoFolderForSku } from "./Product"
|
||||
import { Config } from "./config"
|
||||
import {
|
||||
getColumnByName,
|
||||
getCellRangeByColumnName,
|
||||
@ -5,6 +7,7 @@ import {
|
||||
getColumnValuesByName,
|
||||
} from "./sheetUtils"
|
||||
|
||||
const LOCK_TIMEOUT_MS = 1000 * 10
|
||||
|
||||
export function newSkuHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
|
||||
var sheet = SpreadsheetApp.getActive().getActiveSheet()
|
||||
@ -20,7 +23,19 @@ export function newSkuHandler(e: GoogleAppsScript.Events.SheetsOnEdit) {
|
||||
console.log("new ID was not requested, returning")
|
||||
return
|
||||
}
|
||||
newSku(row)
|
||||
// Acquire a user lock to prevent multiple onEdit calls from clashing
|
||||
const documentLock = LockService.getDocumentLock()
|
||||
try {
|
||||
const config = new (Config);
|
||||
documentLock.waitLock(LOCK_TIMEOUT_MS)
|
||||
const sku = newSku(row)
|
||||
console.log("new sku: " + sku)
|
||||
createPhotoFolderForSku(config, String(sku))
|
||||
} catch (error) {
|
||||
console.log("Error in newSkuHandler: " + error.message)
|
||||
} finally {
|
||||
documentLock.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
export function newSku(row: number) {
|
||||
@ -62,4 +77,6 @@ export function newSku(row: number) {
|
||||
let newId = maxId + 1
|
||||
console.log("newId: " + newId)
|
||||
idCell.setValue(newId)
|
||||
|
||||
return `${skuPrefixCellValue}-${newId.toString().padStart(4, "0")}`
|
||||
}
|
||||
|
||||
119
src/onEditQueue.ts
Normal file
119
src/onEditQueue.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { getCellValueByColumnName } from "./sheetUtils"
|
||||
import { Product } from "./Product"
|
||||
import { Shop } from "./shopifyApi"
|
||||
|
||||
// --- Constants ---
|
||||
const BATCH_INTERVAL_MS = 30 * 1000 // 30 seconds
|
||||
const LOCK_TIMEOUT_MS = 10 * 1000 // 10 seconds for lock acquisition
|
||||
const CACHE_KEY_EDITS = "pendingEdits"
|
||||
const CACHE_KEY_LAST_EDIT_TIME = "lastEditTime"
|
||||
const SCRIPT_PROPERTY_TRIGGER_SCHEDULED = "batchTriggerScheduled"
|
||||
|
||||
export function onEditQueue(e) {
|
||||
const sheet = e.source.getActiveSheet()
|
||||
if (sheet.getName() !== "product_inventory") {
|
||||
console.log("skipping edit on sheet " + sheet.getName())
|
||||
return
|
||||
}
|
||||
const range = e.range
|
||||
const row = range.getRow()
|
||||
const sku = getCellValueByColumnName(sheet, "sku", row)
|
||||
if (!sku) {
|
||||
console.log("No SKU found for row " + row)
|
||||
return
|
||||
}
|
||||
// Make sure SKU conforms to expected patterns
|
||||
if (sku.match(`\\?`) || sku.match(`n$`)) {
|
||||
console.log("SKU is a placeholder ('?' or 'n...'), skipping batching.")
|
||||
return
|
||||
}
|
||||
// Acquire a user lock to prevent multiple onEdit calls from clashing
|
||||
const documentLock = LockService.getDocumentLock()
|
||||
try {
|
||||
documentLock.waitLock(LOCK_TIMEOUT_MS)
|
||||
|
||||
const scriptProperties = PropertiesService.getScriptProperties() // Shared cache for all users
|
||||
|
||||
// 1. Accumulate edits in cache
|
||||
let pendingEdits = []
|
||||
try {
|
||||
pendingEdits = JSON.parse(
|
||||
scriptProperties.getProperty(CACHE_KEY_EDITS) || "[]"
|
||||
)
|
||||
} catch (e) {
|
||||
console.log("Cache corruption: " + e.message)
|
||||
scriptProperties.setProperty(CACHE_KEY_EDITS, "[]")
|
||||
}
|
||||
const existingIndex = pendingEdits.findIndex((item) => item.sku === sku)
|
||||
if (existingIndex !== -1) {
|
||||
console.log("New edit on queued SKU '"+sku+"', resetting timer...")
|
||||
pendingEdits[existingIndex].timestamp = Date.now()
|
||||
} else {
|
||||
console.log("New SKU '"+sku+"' added to queue.")
|
||||
pendingEdits.push({ sku, timestamp: Date.now() })
|
||||
}
|
||||
scriptProperties.setProperty(CACHE_KEY_EDITS, JSON.stringify(pendingEdits))
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"Error in onEdit (lock acquisition or cache operation): " + error.message
|
||||
)
|
||||
} finally {
|
||||
documentLock.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
export function processBatchedEdits() {
|
||||
const scriptLock = LockService.getScriptLock() // Use script lock for the processing function
|
||||
try {
|
||||
scriptLock.waitLock(LOCK_TIMEOUT_MS)
|
||||
|
||||
const scriptProperties = PropertiesService.getScriptProperties()
|
||||
|
||||
let pendingEdits = []
|
||||
try {
|
||||
const pendingEditsStr = scriptProperties.getProperty(CACHE_KEY_EDITS)
|
||||
pendingEdits = pendingEditsStr ? JSON.parse(pendingEditsStr) : []
|
||||
} catch (e) {
|
||||
console.log("Cache corruption: " + e.message)
|
||||
scriptProperties.setProperty(CACHE_KEY_EDITS, "[]")
|
||||
}
|
||||
|
||||
console.log(`Total SKUs in queue: ${pendingEdits.length}`)
|
||||
const now = Date.now()
|
||||
const toProcess = pendingEdits.filter(
|
||||
(edit) => now - edit.timestamp > BATCH_INTERVAL_MS
|
||||
)
|
||||
if (toProcess.length > 0) {
|
||||
let shop = new Shop()
|
||||
console.log(`Processing ${toProcess.length} SKUs...`)
|
||||
toProcess.forEach((edit) => {
|
||||
console.log(
|
||||
`Processing SKU ${edit.sku}, Timestamp: ${new Date(edit.timestamp)}`
|
||||
)
|
||||
// Make sure SKU conforms to expected patterns
|
||||
if (!edit.sku.match(/^\w+-\d{4}$/)) {
|
||||
console.log(`SKU ${edit.sku} is not valid, skipping processing.`)
|
||||
return
|
||||
}
|
||||
let p = new Product(edit.sku)
|
||||
p.UpdateShopifyProduct(shop)
|
||||
})
|
||||
|
||||
pendingEdits = pendingEdits.filter(
|
||||
edit => !toProcess.some(p => p.sku === edit.sku)
|
||||
);
|
||||
scriptProperties.setProperty(CACHE_KEY_EDITS, JSON.stringify(pendingEdits));
|
||||
|
||||
console.log(`Processed ${toProcess.length} edits.`)
|
||||
} else {
|
||||
console.log("No pending edits to process.")
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"Error in processBatchedEdits (lock acquisition or processing): " +
|
||||
error.message
|
||||
)
|
||||
} finally {
|
||||
scriptLock.releaseLock()
|
||||
}
|
||||
}
|
||||
@ -50,6 +50,16 @@ export function getColumnValuesByName(
|
||||
}
|
||||
}
|
||||
|
||||
export function getColumnRichTextByName(
|
||||
sheet: GoogleAppsScript.Spreadsheet.Sheet,
|
||||
columnName: string
|
||||
) {
|
||||
let column = getColumnRangeByName(sheet, columnName)
|
||||
if (column != null) {
|
||||
return column.getRichTextValues()
|
||||
}
|
||||
}
|
||||
|
||||
export function vlookupByColumns(
|
||||
sheetName: string,
|
||||
searchColumn: string,
|
||||
|
||||
@ -10,5 +10,11 @@ export function reinstallTriggers() {
|
||||
.forSpreadsheet(ss)
|
||||
.onEdit()
|
||||
.create()
|
||||
ScriptApp.newTrigger("columnOnEditHandler").forSpreadsheet(ss).onEdit().create()
|
||||
// ScriptApp.newTrigger("columnOnEditHandler").forSpreadsheet(ss).onEdit().create()
|
||||
// ScriptApp.newTrigger("onEditQueue").forSpreadsheet(ss).onEdit().create()
|
||||
ScriptApp.newTrigger("onEditQueue").forSpreadsheet(ss).onEdit().create()
|
||||
ScriptApp.newTrigger("processBatchedEdits")
|
||||
.timeBased()
|
||||
.everyMinutes(1)
|
||||
.create()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user