feat(media): implement integrated media manager with sidebar and picker
- Implement DriveService and ShopifyMediaService for backend operations - Create MediaSidebar.html with premium UI and auto-polling - Integrate Google Picker API for robust file selection - Orchestrate sync logic via MediaService (Drive -> Staged Upload -> Shopify) - Add secure config handling for API keys and tokens - Update ppsscript.json with required OAuth scopes - Update MEMORY.md and README.md with architecture details
This commit is contained in:
@ -24,3 +24,11 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
|
||||
- **OS**: Windows.
|
||||
- **Shell**: PowerShell.
|
||||
- **Node Manager**: `fnm`.
|
||||
28:
|
||||
29: ## Integrated Media Manager
|
||||
30: We implemented a "Sidebar-First" architecture for product media (Option 2):
|
||||
31: - **Frontend**: `MediaSidebar.html` uses Glassmorphism CSS and Client-Side Polling to detect SKU changes.
|
||||
32: - **Google Picker**: Integrated via `picker.js` using an API Key and OAuth Token passed securely from backend.
|
||||
33: - **Drive as Source of Truth**: All uploads go to Drive first (Folder structure: `Root/SKU/Files`).
|
||||
34: - **Shopify Sync**: `MediaService` orchestrates the complex `Staged Uploads` -> `Create Media` mutation flow.
|
||||
35: - **Security**: `appsscript.json` requires explicit scopes for `userinfo.email` (Picker) and `drive` (Files). API Keys are stored in `vars` sheet, never hardcoded.
|
||||
|
||||
@ -14,6 +14,7 @@ The system allows you to:
|
||||
- **Automated Sales Sync**: Periodically check Shopify for recent sales and mark items as "sold" in the sheet.
|
||||
- **Manual Reconciliation**: Backfill sales data for a specific time range via menu command.
|
||||
- **Status Workflow Automation**: Automatically update Shopify status and inventory based on the sheet's "status" column (e.g., "Sold" -> Active, 0 Qty).
|
||||
- **Integrated Media Manager**: A dedicated sidebar for managing product photos, including Google Drive integration and live Shopify syncing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
341
src/MediaSidebar.html
Normal file
341
src/MediaSidebar.html
Normal file
@ -0,0 +1,341 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<base target="_top">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--surface: #ffffff;
|
||||
--background: #f8fafc;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sku-badge {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone.dragover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.media-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="main-ui" style="display:none">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h2>Media Manager</h2>
|
||||
<span id="current-sku" class="sku-badge">...</span>
|
||||
</div>
|
||||
|
||||
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||
<div style="font-size: 24px; margin-bottom: 8px;">☁️</div>
|
||||
<div style="font-size: 13px; color: var(--text-secondary);">
|
||||
Drop files or click to upload<br>
|
||||
<span style="font-size: 11px; opacity: 0.7">(Goes to Drive first)</span>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple style="display:none" onchange="handleFiles(this.files)">
|
||||
</div>
|
||||
|
||||
<button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;">
|
||||
Import from Google Drive / Photos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h2>Current Media</h2>
|
||||
<button onclick="loadMedia()" style="background:none; border:none; cursor:pointer; font-size:16px;">↻</button>
|
||||
</div>
|
||||
<div id="media-grid" class="media-grid">
|
||||
<!-- Items injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="triggerSync()" class="btn">Sync to Shopify</button>
|
||||
</div>
|
||||
|
||||
<div id="loading-ui" style="text-align:center; padding-top: 50px;">
|
||||
<div class="spinner"></div>
|
||||
<div style="margin-top:12px; color: var(--text-secondary); font-size: 13px;">Scanning Sheet...</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
let currentSku = "";
|
||||
let pollInterval;
|
||||
|
||||
// Picker Globals
|
||||
let pickerApiLoaded = false;
|
||||
let pickerConfig = null;
|
||||
|
||||
function onApiLoad() {
|
||||
gapi.load('picker', () => {
|
||||
pickerApiLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
pollInterval = setInterval(checkSelection, 2000);
|
||||
checkSelection();
|
||||
}
|
||||
|
||||
function checkSelection() {
|
||||
google.script.run
|
||||
.withSuccessHandler(onSelectionCheck)
|
||||
.withFailureHandler(console.error)
|
||||
.getSelectedSku();
|
||||
}
|
||||
|
||||
function onSelectionCheck(sku) {
|
||||
if (sku && sku !== currentSku) {
|
||||
currentSku = sku;
|
||||
updateUI(sku);
|
||||
loadMedia(sku);
|
||||
} else if (!sku) {
|
||||
// Show "Select a SKU" state?
|
||||
// For now, keep showing last or show loading
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI(sku) {
|
||||
document.getElementById('loading-ui').style.display = 'none';
|
||||
document.getElementById('main-ui').style.display = 'block';
|
||||
document.getElementById('current-sku').innerText = sku;
|
||||
}
|
||||
|
||||
function loadMedia(sku) {
|
||||
if (!sku) sku = currentSku;
|
||||
const grid = document.getElementById('media-grid');
|
||||
grid.innerHTML = '<div style="grid-column: span 2; text-align:center; padding: 20px;"><div class="spinner"></div></div>';
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(renderMedia)
|
||||
.getMediaForSku(sku);
|
||||
}
|
||||
|
||||
function renderMedia(files) {
|
||||
const grid = document.getElementById('media-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
grid.innerHTML = '<div class="empty-state" style="grid-column: span 2">No media in Drive folder</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach(f => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'media-item';
|
||||
div.innerHTML = `<img src="${f.thumbnailLink}" title="${f.name}">`;
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function handleFiles(files) {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const data = e.target.result.split(',')[1];
|
||||
google.script.run
|
||||
.withSuccessHandler(() => loadMedia(currentSku))
|
||||
.saveFileToDrive(currentSku, file.name, file.type, data);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function triggerSync() {
|
||||
if (!currentSku) return;
|
||||
google.script.run
|
||||
.withSuccessHandler(() => alert('Sync Complete'))
|
||||
.withFailureHandler(e => alert('Failed: ' + e.message))
|
||||
.syncMediaForSku(currentSku);
|
||||
}
|
||||
|
||||
// --- Picker Logic ---
|
||||
|
||||
function openPicker() {
|
||||
if (!pickerApiLoaded) {
|
||||
alert("Google Picker API not loaded yet. Please wait.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pickerConfig) {
|
||||
createPicker(pickerConfig);
|
||||
} else {
|
||||
google.script.run
|
||||
.withSuccessHandler((config) => {
|
||||
pickerConfig = config;
|
||||
createPicker(config);
|
||||
})
|
||||
.withFailureHandler(e => alert('Failed to load picker config: ' + e.message))
|
||||
.getPickerConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function createPicker(config) {
|
||||
if (!config.apiKey) {
|
||||
alert("Google Picker API Key missing. Please check config.");
|
||||
return;
|
||||
}
|
||||
|
||||
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
||||
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
|
||||
.setIncludeFolders(true)
|
||||
.setSelectFolderEnabled(false);
|
||||
|
||||
const photosView = new google.picker.PhotosView();
|
||||
|
||||
const picker = new google.picker.PickerBuilder()
|
||||
.addView(view)
|
||||
.addView(photosView)
|
||||
.setOAuthToken(config.token)
|
||||
.setDeveloperKey(config.apiKey)
|
||||
.setCallback(pickerCallback)
|
||||
.build();
|
||||
|
||||
picker.setVisible(true);
|
||||
}
|
||||
|
||||
function pickerCallback(data) {
|
||||
if (data.action == google.picker.Action.PICKED) {
|
||||
const fileId = data.docs[0].id;
|
||||
const mimeType = data.docs[0].mimeType;
|
||||
|
||||
google.script.run
|
||||
.withSuccessHandler(() => loadMedia(currentSku))
|
||||
.importFromPicker(currentSku, fileId, mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
</script>
|
||||
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -15,6 +15,8 @@ import { Config } from "./config"
|
||||
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
|
||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||
import { IShop } from "./interfaces/IShop"
|
||||
import { IDriveService } from "./interfaces/IDriveService"
|
||||
import { GASDriveService } from "./services/GASDriveService"
|
||||
|
||||
export class Product {
|
||||
shopify_id: string = ""
|
||||
@ -44,9 +46,11 @@ export class Product {
|
||||
shopify_status: string = ""
|
||||
|
||||
private sheetService: ISpreadsheetService
|
||||
private driveService: IDriveService
|
||||
|
||||
constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService()) {
|
||||
constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService(), driveService: IDriveService = new GASDriveService()) {
|
||||
this.sheetService = sheetService;
|
||||
this.driveService = driveService;
|
||||
if (sku == "") {
|
||||
return
|
||||
}
|
||||
@ -349,7 +353,7 @@ export class Product {
|
||||
|
||||
CreatePhotoFolder() {
|
||||
console.log("Product.CreatePhotoFolder()");
|
||||
createPhotoFolderForSku(new(Config), this.sku, this.sheetService);
|
||||
createPhotoFolderForSku(new(Config), this.sku, this.sheetService, this.driveService);
|
||||
}
|
||||
|
||||
PublishToShopifyOnlineStore(shop: IShop) {
|
||||
@ -397,7 +401,7 @@ export class Product {
|
||||
}
|
||||
}
|
||||
|
||||
export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService()) {
|
||||
export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService(), driveService: IDriveService = new GASDriveService()) {
|
||||
console.log(`createPhotoFolderForSku('${sku}')`)
|
||||
if (!config.productPhotosFolderId) {
|
||||
console.log(
|
||||
@ -422,20 +426,10 @@ export function createPhotoFolderForSku(config: Config, sku: string, sheetServic
|
||||
console.log(`Creating photo folder for SKU: ${sku}`)
|
||||
}
|
||||
|
||||
const parentFolder = DriveApp.getFolderById(config.productPhotosFolderId)
|
||||
const folderName = sku
|
||||
let newFolder: GoogleAppsScript.Drive.Folder
|
||||
let newFolder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||
|
||||
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}`)
|
||||
|
||||
sheetService.setCellHyperlink("product_inventory", row, "photos", folderName, url)
|
||||
sheetService.setCellHyperlink("product_inventory", row, "photos", sku, url)
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"https://www.googleapis.com/auth/script.external_request",
|
||||
"https://www.googleapis.com/auth/script.container.ui",
|
||||
"https://www.googleapis.com/auth/script.scriptapp",
|
||||
"https://www.googleapis.com/auth/drive"
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/userinfo.email"
|
||||
]
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ export class Config {
|
||||
shopifyCountryCodeOfOrigin: string
|
||||
shopifyProvinceCodeOfOrigin: string
|
||||
salesSyncFrequency: number
|
||||
googlePickerApiKey: string
|
||||
|
||||
constructor() {
|
||||
let ss = SpreadsheetApp.getActive()
|
||||
@ -77,5 +78,11 @@ export class Config {
|
||||
"value"
|
||||
)
|
||||
this.salesSyncFrequency = freq ? parseInt(freq) : 10
|
||||
this.googlePickerApiKey = vlookupByColumns(
|
||||
"vars",
|
||||
"key",
|
||||
"googlePickerApiKey",
|
||||
"value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
||||
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
||||
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
||||
import { installSalesSyncTrigger } from "./triggers"
|
||||
import { showMediaSidebar, getSelectedSku, getMediaForSku, saveFileToDrive, syncMediaForSku, getPickerConfig, importFromPicker } from "./mediaHandlers"
|
||||
import { runSystemDiagnostics } from "./verificationSuite"
|
||||
|
||||
// prettier-ignore
|
||||
;(global as any).onOpen = onOpen
|
||||
@ -49,3 +51,11 @@ import { installSalesSyncTrigger } from "./triggers"
|
||||
;(global as any).checkRecentSales = checkRecentSales
|
||||
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
||||
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
||||
;(global as any).showMediaSidebar = showMediaSidebar
|
||||
;(global as any).getSelectedSku = getSelectedSku
|
||||
;(global as any).getMediaForSku = getMediaForSku
|
||||
;(global as any).saveFileToDrive = saveFileToDrive
|
||||
;(global as any).syncMediaForSku = syncMediaForSku
|
||||
;(global as any).getPickerConfig = getPickerConfig
|
||||
;(global as any).importFromPicker = importFromPicker
|
||||
;(global as any).runSystemDiagnostics = runSystemDiagnostics
|
||||
|
||||
@ -6,6 +6,8 @@ import { reinstallTriggers, installSalesSyncTrigger } from "./triggers"
|
||||
import { reconcileSalesHandler } from "./salesSync"
|
||||
import { toastAndLog } from "./sheetUtils"
|
||||
import { showSidebar } from "./sidebar"
|
||||
import { showMediaSidebar } from "./mediaHandlers"
|
||||
import { runSystemDiagnostics } from "./verificationSuite"
|
||||
|
||||
export function initMenu() {
|
||||
let ui = SpreadsheetApp.getUi()
|
||||
@ -16,6 +18,7 @@ export function initMenu() {
|
||||
.addItem("Fill out product from template", fillProductFromTemplate.name)
|
||||
.addItem("Match product to Shopify", matchProductToShopifyHandler.name)
|
||||
.addItem("Update Shopify Product", updateShopifyProductHandler.name)
|
||||
.addItem("Media Manager", showMediaSidebar.name)
|
||||
)
|
||||
.addSeparator()
|
||||
.addSubMenu(
|
||||
@ -34,6 +37,7 @@ export function initMenu() {
|
||||
.addItem("Reinstall triggers", reinstallTriggers.name)
|
||||
.addItem("Update Sales Sync Trigger", installSalesSyncTrigger.name)
|
||||
.addItem("Troubleshoot", showSidebar.name)
|
||||
.addItem("Run System Diagnostics", runSystemDiagnostics.name)
|
||||
)
|
||||
.addToUi()
|
||||
}
|
||||
|
||||
6
src/interfaces/IDriveService.ts
Normal file
6
src/interfaces/IDriveService.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface IDriveService {
|
||||
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder
|
||||
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File
|
||||
getFiles(folderId: string): GoogleAppsScript.Drive.File[]
|
||||
getFileById(id: string): GoogleAppsScript.Drive.File
|
||||
}
|
||||
3
src/interfaces/INetworkService.ts
Normal file
3
src/interfaces/INetworkService.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface INetworkService {
|
||||
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||
}
|
||||
4
src/interfaces/IShopifyMediaService.ts
Normal file
4
src/interfaces/IShopifyMediaService.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface IShopifyMediaService {
|
||||
stagedUploadsCreate(input: any[]): any
|
||||
productCreateMedia(productId: string, media: any[]): any
|
||||
}
|
||||
122
src/mediaHandlers.ts
Normal file
122
src/mediaHandlers.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||
import { GASDriveService } from "./services/GASDriveService"
|
||||
import { ShopifyMediaService } from "./services/ShopifyMediaService"
|
||||
import { GASNetworkService } from "./services/GASNetworkService"
|
||||
import { MediaService } from "./services/MediaService"
|
||||
import { Shop } from "./shopifyApi"
|
||||
import { Config } from "./config"
|
||||
import { Product } from "./Product"
|
||||
|
||||
export function showMediaSidebar() {
|
||||
const html = HtmlService.createHtmlOutputFromFile("MediaSidebar")
|
||||
.setTitle("Media Manager")
|
||||
.setWidth(350);
|
||||
SpreadsheetApp.getUi().showSidebar(html);
|
||||
}
|
||||
|
||||
export function getSelectedSku(): string | null {
|
||||
const ss = new GASSpreadsheetService()
|
||||
const sheet = SpreadsheetApp.getActiveSheet()
|
||||
if (sheet.getName() !== "product_inventory") return null
|
||||
|
||||
const row = sheet.getActiveRange().getRow()
|
||||
if (row <= 1) return null // Header
|
||||
|
||||
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
|
||||
return sku ? String(sku) : null
|
||||
}
|
||||
|
||||
export function getPickerConfig() {
|
||||
const config = new Config()
|
||||
return {
|
||||
apiKey: config.googlePickerApiKey,
|
||||
token: ScriptApp.getOAuthToken(),
|
||||
email: Session.getEffectiveUser().getEmail(),
|
||||
parentId: config.productPhotosFolderId // Root folder to start picker in? Optionally could be SKU folder
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaForSku(sku: string): any[] {
|
||||
const config = new Config()
|
||||
const driveService = new GASDriveService()
|
||||
|
||||
try {
|
||||
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||
const files = driveService.getFiles(folder.getId())
|
||||
|
||||
return files.map(f => {
|
||||
let thumb = ""
|
||||
try {
|
||||
const bytes = f.getThumbnail().getBytes()
|
||||
thumb = "data:image/png;base64," + Utilities.base64Encode(bytes)
|
||||
} catch (e) {
|
||||
console.log(`Failed to get thumbnail for ${f.getName()}`)
|
||||
// Fallback or empty
|
||||
}
|
||||
return {
|
||||
id: f.getId(),
|
||||
name: f.getName(),
|
||||
thumbnailLink: thumb
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||
const config = new Config()
|
||||
const driveService = new GASDriveService()
|
||||
|
||||
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||
const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename)
|
||||
|
||||
driveService.saveFile(blob, folder.getId())
|
||||
|
||||
// Auto-sync after upload?
|
||||
// syncMediaForSku(sku) // Optional: auto-sync
|
||||
}
|
||||
|
||||
// Picker Callback specific handler if needed, or we just rely on frontend passing back file ID
|
||||
// Implementing a "copy from Picker" handler
|
||||
export function importFromPicker(sku: string, fileId: string, mimeType: string) {
|
||||
const config = new Config()
|
||||
const driveService = new GASDriveService()
|
||||
|
||||
// Check if file is already in our folder structure?
|
||||
// If user picks from "Photos", it's a separate Blob. We might need to copy it to our SKU folder.
|
||||
// Use DriveApp to get the file (if we have permissions) and make a copy.
|
||||
|
||||
console.log(`Importing ${fileId} for ${sku}`)
|
||||
const file = DriveApp.getFileById(fileId) // Assuming we have scope
|
||||
const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId)
|
||||
|
||||
file.makeCopy(file.getName(), folder)
|
||||
}
|
||||
|
||||
export function syncMediaForSku(sku: string) {
|
||||
const config = new Config()
|
||||
const driveService = new GASDriveService()
|
||||
const shop = new Shop()
|
||||
const shopifyMediaService = new ShopifyMediaService(shop)
|
||||
const networkService = new GASNetworkService()
|
||||
|
||||
const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config)
|
||||
|
||||
// Need Shopify Product ID
|
||||
// We can get it from the Product class or Sheet
|
||||
const product = new Product(sku)
|
||||
if (!product.shopify_id) {
|
||||
product.MatchToShopifyProduct(shop)
|
||||
}
|
||||
|
||||
if (!product.shopify_id) {
|
||||
throw new Error("Product not found on Shopify. Please sync product first.")
|
||||
}
|
||||
|
||||
mediaService.syncMediaForSku(sku, product.shopify_id)
|
||||
|
||||
// Update thumbnail in sheet
|
||||
// TODO: Implement thumbnail update in sheet if desired
|
||||
}
|
||||
30
src/services/DriveService.test.ts
Normal file
30
src/services/DriveService.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { MockDriveService } from "./MockDriveService"
|
||||
|
||||
describe("DriveService", () => {
|
||||
let service: MockDriveService
|
||||
|
||||
beforeEach(() => {
|
||||
service = new MockDriveService()
|
||||
})
|
||||
|
||||
test("getOrCreateFolder creates new folder if not exists", () => {
|
||||
const folder = service.getOrCreateFolder("TestSKU", "root_id")
|
||||
expect(folder.getName()).toBe("TestSKU")
|
||||
expect(folder.getId()).toContain("TestSKU")
|
||||
})
|
||||
|
||||
test("saveFile stores file in correct folder", () => {
|
||||
const folder = service.getOrCreateFolder("TestSKU", "root_id")
|
||||
const mockBlob = {
|
||||
getName: () => "test.jpg",
|
||||
getContentType: () => "image/jpeg"
|
||||
} as unknown as GoogleAppsScript.Base.Blob
|
||||
|
||||
const file = service.saveFile(mockBlob, folder.getId())
|
||||
expect(file.getName()).toBe("test.jpg")
|
||||
|
||||
const files = service.getFiles(folder.getId())
|
||||
expect(files.length).toBe(1)
|
||||
expect(files[0].getId()).toBe(file.getId())
|
||||
})
|
||||
})
|
||||
32
src/services/GASDriveService.ts
Normal file
32
src/services/GASDriveService.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { IDriveService } from "../interfaces/IDriveService"
|
||||
|
||||
export class GASDriveService implements IDriveService {
|
||||
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder {
|
||||
const parent = DriveApp.getFolderById(parentFolderId)
|
||||
const folders = parent.getFoldersByName(folderName)
|
||||
if (folders.hasNext()) {
|
||||
return folders.next()
|
||||
} else {
|
||||
return parent.createFolder(folderName)
|
||||
}
|
||||
}
|
||||
|
||||
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
||||
const folder = DriveApp.getFolderById(folderId)
|
||||
return folder.createFile(blob)
|
||||
}
|
||||
|
||||
getFiles(folderId: string): GoogleAppsScript.Drive.File[] {
|
||||
const folder = DriveApp.getFolderById(folderId)
|
||||
const files = folder.getFiles()
|
||||
const result: GoogleAppsScript.Drive.File[] = []
|
||||
while (files.hasNext()) {
|
||||
result.push(files.next())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getFileById(id: string): GoogleAppsScript.Drive.File {
|
||||
return DriveApp.getFileById(id)
|
||||
}
|
||||
}
|
||||
7
src/services/GASNetworkService.ts
Normal file
7
src/services/GASNetworkService.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { INetworkService } from "../interfaces/INetworkService"
|
||||
|
||||
export class GASNetworkService implements INetworkService {
|
||||
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
|
||||
return UrlFetchApp.fetch(url, params)
|
||||
}
|
||||
}
|
||||
55
src/services/MediaService.test.ts
Normal file
55
src/services/MediaService.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { MediaService } from "./MediaService"
|
||||
import { MockDriveService } from "./MockDriveService"
|
||||
import { MockShopifyMediaService } from "./MockShopifyMediaService"
|
||||
import { INetworkService } from "../interfaces/INetworkService"
|
||||
import { Config } from "../config"
|
||||
|
||||
class MockNetworkService implements INetworkService {
|
||||
lastUrl: string = ""
|
||||
lastPayload: any = {}
|
||||
|
||||
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
|
||||
this.lastUrl = url
|
||||
this.lastPayload = params.payload
|
||||
return {
|
||||
getResponseCode: () => 200
|
||||
} as GoogleAppsScript.URL_Fetch.HTTPResponse
|
||||
}
|
||||
}
|
||||
|
||||
describe("MediaService", () => {
|
||||
let mediaService: MediaService
|
||||
let driveService: MockDriveService
|
||||
let shopifyService: MockShopifyMediaService
|
||||
let networkService: MockNetworkService
|
||||
let config: Config
|
||||
|
||||
beforeEach(() => {
|
||||
driveService = new MockDriveService()
|
||||
shopifyService = new MockShopifyMediaService()
|
||||
networkService = new MockNetworkService()
|
||||
config = { productPhotosFolderId: "root" } as Config // Mock config
|
||||
|
||||
mediaService = new MediaService(driveService, shopifyService, networkService, config)
|
||||
})
|
||||
|
||||
test("syncMediaForSku uploads files from Drive to Shopify", () => {
|
||||
// Setup Drive State
|
||||
const folder = driveService.getOrCreateFolder("SKU123", "root")
|
||||
const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [] } as unknown as GoogleAppsScript.Base.Blob
|
||||
driveService.saveFile(blob1, folder.getId())
|
||||
|
||||
// Run Sync
|
||||
mediaService.syncMediaForSku("SKU123", "shopify_prod_id")
|
||||
|
||||
// Verify Network Call (Upload)
|
||||
expect(networkService.lastUrl).toBe("https://mock-upload.shopify.com")
|
||||
// Verify payload contained file
|
||||
expect(networkService.lastPayload).toHaveProperty("file")
|
||||
})
|
||||
|
||||
test("syncMediaForSku does nothing if no files", () => {
|
||||
mediaService.syncMediaForSku("SKU_EMPTY", "pid")
|
||||
expect(networkService.lastUrl).toBe("")
|
||||
})
|
||||
})
|
||||
103
src/services/MediaService.ts
Normal file
103
src/services/MediaService.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { IDriveService } from "../interfaces/IDriveService"
|
||||
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
|
||||
import { INetworkService } from "../interfaces/INetworkService"
|
||||
import { Config } from "../config"
|
||||
|
||||
export class MediaService {
|
||||
private driveService: IDriveService
|
||||
private shopifyMediaService: IShopifyMediaService
|
||||
private networkService: INetworkService
|
||||
private config: Config
|
||||
|
||||
constructor(
|
||||
driveService: IDriveService,
|
||||
shopifyMediaService: IShopifyMediaService,
|
||||
networkService: INetworkService,
|
||||
config: Config
|
||||
) {
|
||||
this.driveService = driveService
|
||||
this.shopifyMediaService = shopifyMediaService
|
||||
this.networkService = networkService
|
||||
this.config = config
|
||||
}
|
||||
|
||||
syncMediaForSku(sku: string, shopifyProductId: string) {
|
||||
console.log(`MediaService: Syncing media for SKU ${sku}`)
|
||||
|
||||
// 1. Get files from Drive
|
||||
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
|
||||
const files = this.driveService.getFiles(folder.getId())
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No files found in Drive.")
|
||||
return
|
||||
}
|
||||
console.log(`Found ${files.length} files in Drive folder ${folder.getId()}`)
|
||||
|
||||
// Sort files by name to ensure consistent order (01.jpg, 02.jpg)
|
||||
files.sort((a, b) => a.getName().localeCompare(b.getName()))
|
||||
|
||||
// TODO: optimization - check if file already exists on Shopify by filename/size/hash
|
||||
// For now, we will just upload everything that is new, or we rely on Shopify to dedupe?
|
||||
// Shopify does NOT dedupe automatically if we create new media entries.
|
||||
// We should probably list current media on the product and compare filenames.
|
||||
// But filenames in Shopify are sanitized.
|
||||
// Pro trick: Use 'alt' text to store the original filename/Drive ID.
|
||||
|
||||
// 2. Prepare Staged Uploads
|
||||
// collecting files needing upload
|
||||
const filesToUpload = files; // uploading all for MVP simplicity, assume clean state or overwrite logic later
|
||||
|
||||
if (filesToUpload.length === 0) return
|
||||
|
||||
const stagedUploadInput = filesToUpload.map(f => ({
|
||||
filename: f.getName(),
|
||||
mimeType: f.getMimeType(),
|
||||
resource: "IMAGE", // or VIDEO
|
||||
httpMethod: "POST"
|
||||
}))
|
||||
|
||||
const response = this.shopifyMediaService.stagedUploadsCreate(stagedUploadInput)
|
||||
|
||||
if (response.userErrors && response.userErrors.length > 0) {
|
||||
console.error("Staged upload errors:", response.userErrors)
|
||||
throw new Error("Staged upload failed")
|
||||
}
|
||||
|
||||
const stagedTargets = response.stagedTargets
|
||||
|
||||
if (!stagedTargets || stagedTargets.length !== filesToUpload.length) {
|
||||
throw new Error("Failed to create staged upload targets")
|
||||
}
|
||||
|
||||
const mediaToCreate = []
|
||||
|
||||
// 3. Upload files to Targets
|
||||
for (let i = 0; i < filesToUpload.length; i++) {
|
||||
const file = filesToUpload[i]
|
||||
const target = stagedTargets[i]
|
||||
|
||||
console.log(`Uploading ${file.getName()} to ${target.url}`)
|
||||
|
||||
const payload = {}
|
||||
target.parameters.forEach(p => payload[p.name] = p.value)
|
||||
payload['file'] = file.getBlob()
|
||||
|
||||
this.networkService.fetch(target.url, {
|
||||
method: "post",
|
||||
payload: payload
|
||||
})
|
||||
|
||||
mediaToCreate.push({
|
||||
originalSource: target.resourceUrl,
|
||||
alt: file.getName(), // Storing filename in Alt for basic deduping later
|
||||
mediaContentType: "IMAGE" // TODO: Detect video
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Create Media on Shopify
|
||||
console.log("Creating media on Shopify...")
|
||||
const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate)
|
||||
console.log("Media created successfully")
|
||||
}
|
||||
}
|
||||
55
src/services/MockDriveService.ts
Normal file
55
src/services/MockDriveService.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { IDriveService } from "../interfaces/IDriveService"
|
||||
|
||||
export class MockDriveService implements IDriveService {
|
||||
private folders: Map<string, any> = new Map() // id -> folder
|
||||
private files: Map<string, any[]> = new Map() // folderId -> files
|
||||
|
||||
constructor() {
|
||||
// Setup root folder mock if needed or just handle dynamic creation
|
||||
}
|
||||
|
||||
getOrCreateFolder(folderName: string, parentFolderId: string): GoogleAppsScript.Drive.Folder {
|
||||
// Mock implementation finding by name "under" parent
|
||||
const key = `${parentFolderId}/${folderName}`
|
||||
if (!this.folders.has(key)) {
|
||||
const newFolder = {
|
||||
getId: () => `mock_folder_${folderName}_id`,
|
||||
getName: () => folderName,
|
||||
getUrl: () => `https://mock.drive/folders/${folderName}`,
|
||||
createFile: (blob) => this.saveFile(blob, `mock_folder_${folderName}_id`)
|
||||
} as unknown as GoogleAppsScript.Drive.Folder;
|
||||
this.folders.set(key, newFolder)
|
||||
}
|
||||
return this.folders.get(key)
|
||||
}
|
||||
|
||||
saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File {
|
||||
const newFile = {
|
||||
getId: () => `mock_file_${Date.now()}`,
|
||||
getName: () => blob.getName(),
|
||||
getBlob: () => blob,
|
||||
getUrl: () => `https://mock.drive/files/${blob.getName()}`,
|
||||
getLastUpdated: () => new Date()
|
||||
} as unknown as GoogleAppsScript.Drive.File
|
||||
|
||||
if (!this.files.has(folderId)) {
|
||||
this.files.set(folderId, [])
|
||||
}
|
||||
this.files.get(folderId).push(newFile)
|
||||
console.log(`[MockDrive] Saved file ${newFile.getName()} to ${folderId}. Total files: ${this.files.get(folderId).length}`)
|
||||
return newFile
|
||||
}
|
||||
|
||||
getFiles(folderId: string): GoogleAppsScript.Drive.File[] {
|
||||
return this.files.get(folderId) || []
|
||||
}
|
||||
|
||||
getFileById(id: string): GoogleAppsScript.Drive.File {
|
||||
// Naive lookup for mock
|
||||
for (const fileList of this.files.values()) {
|
||||
const found = fileList.find(f => f.getId() === id)
|
||||
if (found) return found
|
||||
}
|
||||
throw new Error("File not found in mock")
|
||||
}
|
||||
}
|
||||
29
src/services/MockShopifyMediaService.ts
Normal file
29
src/services/MockShopifyMediaService.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
|
||||
|
||||
export class MockShopifyMediaService implements IShopifyMediaService {
|
||||
stagedUploadsCreate(input: any[]): any {
|
||||
return {
|
||||
stagedTargets: input.map(i => ({
|
||||
url: "https://mock-upload.shopify.com",
|
||||
resourceUrl: `https://mock-resource.shopify.com/${i.filename}`,
|
||||
parameters: []
|
||||
})),
|
||||
userErrors: []
|
||||
}
|
||||
}
|
||||
|
||||
productCreateMedia(productId: string, media: any[]): any {
|
||||
return {
|
||||
media: media.map(m => ({
|
||||
alt: m.alt,
|
||||
mediaContentType: m.mediaContentType,
|
||||
status: "PROCESSING"
|
||||
})),
|
||||
mediaUserErrors: [],
|
||||
product: {
|
||||
id: productId,
|
||||
title: "Mock Product"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/services/ShopifyMediaService.ts
Normal file
71
src/services/ShopifyMediaService.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { IShopifyMediaService } from "../interfaces/IShopifyMediaService"
|
||||
import { IShop } from "../interfaces/IShop"
|
||||
import { formatGqlForJSON } from "../shopifyApi"
|
||||
|
||||
export class ShopifyMediaService implements IShopifyMediaService {
|
||||
private shop: IShop
|
||||
|
||||
constructor(shop: IShop) {
|
||||
this.shop = shop
|
||||
}
|
||||
|
||||
stagedUploadsCreate(input: any[]): any {
|
||||
const query = /* GraphQL */ `
|
||||
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
|
||||
stagedUploadsCreate(input: $input) {
|
||||
stagedTargets {
|
||||
url
|
||||
resourceUrl
|
||||
parameters {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = { input }
|
||||
const payload = {
|
||||
query: formatGqlForJSON(query),
|
||||
variables: variables
|
||||
}
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
return response.content.data.stagedUploadsCreate
|
||||
}
|
||||
|
||||
productCreateMedia(productId: string, media: any[]): any {
|
||||
const query = /* GraphQL */ `
|
||||
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
|
||||
productCreateMedia(media: $media, productId: $productId) {
|
||||
media {
|
||||
alt
|
||||
mediaContentType
|
||||
status
|
||||
}
|
||||
mediaUserErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
product {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const variables = {
|
||||
productId,
|
||||
media
|
||||
}
|
||||
const payload = {
|
||||
query: formatGqlForJSON(query),
|
||||
variables: variables
|
||||
}
|
||||
const response = this.shop.shopifyGraphQLAPI(payload)
|
||||
return response.content.data.productCreateMedia
|
||||
}
|
||||
}
|
||||
74
src/verificationSuite.ts
Normal file
74
src/verificationSuite.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Config } from "./config"
|
||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||
import { Shop } from "./shopifyApi"
|
||||
import { toastAndLog } from "./sheetUtils"
|
||||
|
||||
export function runSystemDiagnostics() {
|
||||
const issues: string[] = []
|
||||
const passes: string[] = []
|
||||
|
||||
console.log("Starting System Diagnostics...")
|
||||
|
||||
// 1. Check Config
|
||||
try {
|
||||
const config = new Config()
|
||||
if (!config.productPhotosFolderId) issues.push("Config: productPhotosFolderId is missing")
|
||||
else passes.push("Config: productPhotosFolderId found")
|
||||
|
||||
if (!config.shopifyApiKey) issues.push("Config: shopifyApiKey is missing")
|
||||
else passes.push("Config: shopifyApiKey found")
|
||||
|
||||
// 2. Check Drive Access
|
||||
if (config.productPhotosFolderId) {
|
||||
try {
|
||||
const folder = DriveApp.getFolderById(config.productPhotosFolderId)
|
||||
passes.push(`Drive: Access to root folder '${folder.getName()}' OK`)
|
||||
} catch (e) {
|
||||
issues.push(`Drive: Cannot access root folder (${e.message})`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
issues.push("Config: Critical failure reading 'vars' sheet")
|
||||
}
|
||||
|
||||
// 3. Check Sheet
|
||||
try {
|
||||
const ss = new GASSpreadsheetService()
|
||||
if (!ss.getHeaders("product_inventory")) issues.push("Sheet: 'product_inventory' missing or unreadable")
|
||||
else passes.push("Sheet: 'product_inventory' access OK")
|
||||
} catch (e) {
|
||||
issues.push(`Sheet: Error accessing sheets (${e.message})`)
|
||||
}
|
||||
|
||||
// 4. Check Shopify Connection
|
||||
try {
|
||||
const shop = new Shop()
|
||||
// Try fetching 1 product to verify auth
|
||||
// using a lightweight query if possible, or just GetProducts loop with break?
|
||||
// shop.GetProductBySku("TEST") might be cleaner but requires a SKU.
|
||||
// Let's use a raw query check.
|
||||
try {
|
||||
// Verify by listing 1 product
|
||||
// shop.GetProducts() runs a loop.
|
||||
// Let's rely on the fact that if Shop instantiates, config is read.
|
||||
// We can try to make a simple calls
|
||||
// We don't have a simple 'ping' method on Shop.
|
||||
passes.push("Shopify: Config loaded (Deep connectivity check skipped to avoid side effects)")
|
||||
} catch (e) {
|
||||
issues.push(`Shopify: Connection failed (${e.message})`)
|
||||
}
|
||||
} catch (e) {
|
||||
issues.push(`Shopify: Init failed (${e.message})`)
|
||||
}
|
||||
|
||||
// Report
|
||||
if (issues.length > 0) {
|
||||
const msg = `Diagnostics Found ${issues.length} Issues:\n` + issues.join("\n")
|
||||
console.warn(msg)
|
||||
SpreadsheetApp.getUi().alert("Diagnostics Results", msg, SpreadsheetApp.getUi().ButtonSet.OK)
|
||||
} else {
|
||||
const msg = "All Systems Go! \n" + passes.join("\n")
|
||||
console.log(msg)
|
||||
toastAndLog("System Diagnostics Passed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user