Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser
|
||||
// Run: node scripts/backfill-project-files-folder.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const raw = parts.join('/');
|
||||
const clean = raw.split('/').filter(p => p && p !== '.');
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
async function mkdir(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (!text.includes('already') && !text.includes('exist')) {
|
||||
console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: projects, error } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)');
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
|
||||
console.log(`Found ${projects.length} projects`);
|
||||
|
||||
for (const p of projects) {
|
||||
const company = safeName(p.company?.name || '');
|
||||
const project = safeName(p.name || '');
|
||||
if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; }
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
|
||||
const targetDir = joinPath(projectDir, '00 Project Files');
|
||||
|
||||
await mkdir(joinPath(CLIENT_ROOT, company));
|
||||
await mkdir(joinPath(CLIENT_ROOT, company, 'Projects'));
|
||||
await mkdir(projectDir);
|
||||
await mkdir(targetDir);
|
||||
console.log(` ✓ ${company} / ${project} / 00 Project Files`);
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
// Backfill FileBrowser folders for all existing projects and tasks.
|
||||
// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/
|
||||
// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/
|
||||
// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, '');
|
||||
const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); }
|
||||
if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..');
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
async function mkdir(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
|
||||
const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` },
|
||||
});
|
||||
// 200 = created, 409 = already exists — both fine
|
||||
if (!res.ok && res.status !== 409) {
|
||||
const text = await res.text();
|
||||
throw new Error(`mkdir ${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.status;
|
||||
}
|
||||
|
||||
async function supabaseFetch(path) {
|
||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
|
||||
headers: {
|
||||
apikey: SUPABASE_KEY,
|
||||
Authorization: `Bearer ${SUPABASE_KEY}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Fetching projects from Supabase...');
|
||||
const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc');
|
||||
console.log(`Found ${projects.length} projects.`);
|
||||
|
||||
console.log('Fetching tasks from Supabase...');
|
||||
const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc');
|
||||
console.log(`Found ${tasks.length} tasks.\n`);
|
||||
|
||||
let created = 0;
|
||||
let existing = 0;
|
||||
let errors = 0;
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────────────────
|
||||
console.log('=== PROJECTS ===');
|
||||
for (const project of projects) {
|
||||
const companyName = project.company?.name;
|
||||
if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; }
|
||||
|
||||
const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
|
||||
const projectsDir = joinPath(companyDir, 'Projects');
|
||||
const projectDir = joinPath(projectsDir, safeName(project.name));
|
||||
|
||||
try {
|
||||
await mkdir(companyDir);
|
||||
await mkdir(projectsDir);
|
||||
const s = await mkdir(projectDir);
|
||||
await mkdir(joinPath(projectDir, '00 Project Files'));
|
||||
await mkdir(joinPath(projectDir, '00 Project Info'));
|
||||
|
||||
if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; }
|
||||
else { console.log(` CREATED ${companyName} / ${project.name}`); created++; }
|
||||
} catch (err) {
|
||||
console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────────────
|
||||
console.log('\n=== TASKS ===');
|
||||
for (const task of tasks) {
|
||||
const projectName = task.project?.name;
|
||||
const companyName = task.project?.company?.name;
|
||||
if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; }
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName));
|
||||
const taskDir = joinPath(projectDir, safeName(task.title));
|
||||
|
||||
try {
|
||||
// Ensure parent exists (idempotent)
|
||||
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName)));
|
||||
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects'));
|
||||
await mkdir(projectDir);
|
||||
const s = await mkdir(taskDir);
|
||||
await mkdir(joinPath(taskDir, 'Working Files'));
|
||||
await mkdir(joinPath(taskDir, 'Request Info'));
|
||||
|
||||
if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; }
|
||||
else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; }
|
||||
} catch (err) {
|
||||
console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`);
|
||||
}
|
||||
|
||||
run().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,165 @@
|
||||
// One-time script: copies existing submission files from Supabase Storage to FileBrowser
|
||||
// Run: node scripts/backfill-request-files.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
// Parse env file
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = String(path || '/').trim().split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const p of parts) {
|
||||
if (p === '.') continue;
|
||||
if (p === '..') throw new Error('path traversal');
|
||||
clean.push(p);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) { return normalizePath(parts.join('/')); }
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${FB_URL}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function mkdir(path) {
|
||||
await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: rows, error } = await admin
|
||||
.from('submission_files')
|
||||
.select(`
|
||||
id, name, storage_path,
|
||||
submission:submissions!inner(
|
||||
id, version_number,
|
||||
task:tasks!inner(
|
||||
id, title,
|
||||
project:projects!inner(
|
||||
id, name,
|
||||
company:companies!inner(name)
|
||||
)
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
|
||||
// Group by task + version
|
||||
const groups = new Map();
|
||||
for (const row of rows || []) {
|
||||
const sub = row.submission;
|
||||
const task = sub?.task;
|
||||
const project = task?.project;
|
||||
const company = project?.company;
|
||||
if (!task || !project || !company) continue;
|
||||
|
||||
const key = `${task.id}::${sub.version_number}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
companyName: company.name,
|
||||
projectName: project.name,
|
||||
taskTitle: task.title,
|
||||
versionNumber: sub.version_number,
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
||||
}
|
||||
|
||||
console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
|
||||
|
||||
let processed = 0, skipped = 0, errors = 0;
|
||||
|
||||
for (const [key, group] of groups) {
|
||||
if (group.files.length === 0) { skipped++; continue; }
|
||||
|
||||
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
||||
const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName));
|
||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
||||
const revDir = joinPath(requestInfoDir, revFolder);
|
||||
|
||||
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
|
||||
|
||||
await mkdir(companyDir);
|
||||
await mkdir(joinPath(companyDir, 'Projects'));
|
||||
await mkdir(projectDir);
|
||||
await mkdir(taskDir);
|
||||
await mkdir(requestInfoDir);
|
||||
await mkdir(revDir);
|
||||
|
||||
for (const file of group.files) {
|
||||
try {
|
||||
const { data: signed, error: signErr } = await admin.storage
|
||||
.from('submissions')
|
||||
.createSignedUrl(file.storage_path, 120);
|
||||
|
||||
if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`);
|
||||
|
||||
const fileRes = await fetch(signed.signedUrl);
|
||||
if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`);
|
||||
const fileBuffer = await fileRes.arrayBuffer();
|
||||
|
||||
const fbFilePath = joinPath(revDir, file.name);
|
||||
await fbFetch('POST', '/api/resources', {
|
||||
params: { path: fbFilePath, override: 'true' },
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: fileBuffer,
|
||||
});
|
||||
|
||||
console.log(` ✓ ${file.name}`);
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${file.name}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,103 @@
|
||||
// One-time: removes leftover '.Project Files' and 'Project Files' folders
|
||||
// that may still exist alongside the renamed '00 Project Files'
|
||||
// Run: node scripts/cleanup-old-project-files-folders.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
async function fbList(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json().catch(() => null);
|
||||
return data?.folders || [];
|
||||
}
|
||||
|
||||
async function fbDelete(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const OLD_NAMES = ['.Project Files', 'Project Files'];
|
||||
|
||||
async function main() {
|
||||
const { data: projects, error } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)');
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
console.log(`Checking ${projects.length} projects...\n`);
|
||||
|
||||
let cleaned = 0;
|
||||
|
||||
for (const p of projects) {
|
||||
const company = safeName(p.company?.name || '');
|
||||
const project = safeName(p.name || '');
|
||||
if (!company || !project) continue;
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
|
||||
const entries = await fbList(projectDir);
|
||||
if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; }
|
||||
|
||||
const names = entries.map(e => e.name);
|
||||
for (const oldName of OLD_NAMES) {
|
||||
if (names.includes(oldName)) {
|
||||
const oldPath = joinPath(projectDir, oldName);
|
||||
try {
|
||||
await fbDelete(oldPath);
|
||||
console.log(` ✓ deleted "${oldName}": ${company} / ${project}`);
|
||||
cleaned++;
|
||||
} catch (err) {
|
||||
console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Cleaned ${cleaned} old folder(s).`);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,104 @@
|
||||
// One-time: renames .00 Project Files → 00 Project Files for all existing project folders
|
||||
// Run: node scripts/rename-project-files-folder.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
async function fbRename(fromPath, toPath) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fbMkdir(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: projects, error } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)');
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
console.log(`Found ${projects.length} projects`);
|
||||
|
||||
for (const p of projects) {
|
||||
const company = safeName(p.company?.name || '');
|
||||
const project = safeName(p.name || '');
|
||||
if (!company || !project) continue;
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
|
||||
const oldPath = joinPath(projectDir, 'Project Files');
|
||||
const newPath = joinPath(projectDir, '00 Project Files');
|
||||
|
||||
try {
|
||||
await fbRename(oldPath, newPath);
|
||||
console.log(` ✓ renamed: ${company} / ${project}`);
|
||||
} catch (err) {
|
||||
// If rename fails (source doesn't exist), ensure new folder exists
|
||||
try {
|
||||
await fbMkdir(newPath);
|
||||
console.log(` + created: ${company} / ${project} (no dot folder found)`);
|
||||
} catch (e2) {
|
||||
console.log(` ~ exists: ${company} / ${project}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user