285 lines
7.5 KiB
JavaScript
Executable File
285 lines
7.5 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Import CSV data into Directus
|
|
*
|
|
* Usage: node import-csv.js <csv-file> <collection-name>
|
|
*
|
|
* Example: node import-csv.js ../data/artworks.csv artworks
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const https = require('https');
|
|
const readline = require('readline');
|
|
|
|
// Configuration
|
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com';
|
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN;
|
|
|
|
// Field mapping from Airtable to Directus
|
|
// Adjust these based on your actual Airtable columns
|
|
const FIELD_MAPPINGS = {
|
|
artworks: {
|
|
'Name': 'title',
|
|
'Title': 'title',
|
|
'Description': 'description',
|
|
'Year': 'year',
|
|
'Medium': 'medium',
|
|
'Dimensions': 'dimensions',
|
|
'Price': 'price',
|
|
'Status': 'status',
|
|
'Series': 'series_name', // Will need to resolve to ID
|
|
'Tags': 'tag_names', // Will need to resolve to IDs
|
|
'Zettle ID': 'zettle_product_id',
|
|
'Image URL': 'image_url', // Will need to upload
|
|
},
|
|
series: {
|
|
'Name': 'name',
|
|
'Description': 'description',
|
|
},
|
|
events: {
|
|
'Name': 'title',
|
|
'Title': 'title',
|
|
'Start Date': 'start_date',
|
|
'End Date': 'end_date',
|
|
'Location': 'location',
|
|
'Description': 'description',
|
|
}
|
|
};
|
|
|
|
function parseCSV(content) {
|
|
const lines = content.split('\n');
|
|
if (lines.length === 0) return [];
|
|
|
|
// Parse header
|
|
const header = parseCSVLine(lines[0]);
|
|
const records = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
|
|
const values = parseCSVLine(line);
|
|
const record = {};
|
|
|
|
header.forEach((col, idx) => {
|
|
record[col] = values[idx] || '';
|
|
});
|
|
|
|
records.push(record);
|
|
}
|
|
|
|
return records;
|
|
}
|
|
|
|
function parseCSVLine(line) {
|
|
const result = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
|
|
if (char === '"') {
|
|
if (inQuotes && line[i + 1] === '"') {
|
|
current += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
}
|
|
} else if (char === ',' && !inQuotes) {
|
|
result.push(current.trim());
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
|
|
result.push(current.trim());
|
|
return result;
|
|
}
|
|
|
|
function mapFields(record, collection) {
|
|
const mapping = FIELD_MAPPINGS[collection] || {};
|
|
const mapped = {};
|
|
|
|
for (const [airtableField, value] of Object.entries(record)) {
|
|
const directusField = mapping[airtableField] || airtableField.toLowerCase().replace(/\s+/g, '_');
|
|
|
|
// Skip empty values
|
|
if (value === '' || value === undefined) continue;
|
|
|
|
// Type conversions
|
|
if (directusField === 'year' || directusField === 'price') {
|
|
const num = parseFloat(value.replace(/[^0-9.-]/g, ''));
|
|
if (!isNaN(num)) mapped[directusField] = num;
|
|
} else if (directusField === 'status') {
|
|
// Map common status values
|
|
const statusMap = {
|
|
'available': 'published',
|
|
'sold': 'sold',
|
|
'draft': 'draft',
|
|
'published': 'published',
|
|
'archived': 'archived'
|
|
};
|
|
mapped[directusField] = statusMap[value.toLowerCase()] || 'draft';
|
|
} else if (directusField.endsWith('_date')) {
|
|
// Parse dates
|
|
const date = new Date(value);
|
|
if (!isNaN(date)) {
|
|
mapped[directusField] = date.toISOString().split('T')[0];
|
|
}
|
|
} else {
|
|
mapped[directusField] = value;
|
|
}
|
|
}
|
|
|
|
// Set default status if not present
|
|
if (!mapped.status) {
|
|
mapped.status = 'draft';
|
|
}
|
|
|
|
return mapped;
|
|
}
|
|
|
|
async function createItem(collection, data) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(`/items/${collection}`, DIRECTUS_URL);
|
|
const postData = JSON.stringify(data);
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
|
|
'Content-Length': Buffer.byteLength(postData)
|
|
}
|
|
};
|
|
|
|
const req = https.request(url, options, (res) => {
|
|
let body = '';
|
|
res.on('data', chunk => body += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const result = JSON.parse(body);
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve(result.data);
|
|
} else {
|
|
reject(new Error(`HTTP ${res.statusCode}: ${JSON.stringify(result.errors || result)}`));
|
|
}
|
|
} catch (e) {
|
|
reject(new Error(`Parse error: ${body}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.write(postData);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length < 2) {
|
|
console.log('Usage: node import-csv.js <csv-file> <collection-name>');
|
|
console.log('');
|
|
console.log('Example: node import-csv.js ../data/artworks.csv artworks');
|
|
console.log('');
|
|
console.log('Environment variables:');
|
|
console.log(' DIRECTUS_URL - Directus API URL (default: https://katheryn-cms.jeffemmett.com)');
|
|
console.log(' DIRECTUS_TOKEN - Directus admin token (required)');
|
|
console.log('');
|
|
console.log('Supported collections: artworks, series, events');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!DIRECTUS_TOKEN) {
|
|
console.error('Error: DIRECTUS_TOKEN environment variable is required');
|
|
console.error('');
|
|
console.error('To get a token:');
|
|
console.error('1. Log in to Directus admin at ' + DIRECTUS_URL);
|
|
console.error('2. Go to Settings → Access Tokens');
|
|
console.error('3. Create a new static token');
|
|
process.exit(1);
|
|
}
|
|
|
|
const [csvFile, collection] = args;
|
|
const csvPath = path.resolve(csvFile);
|
|
|
|
if (!fs.existsSync(csvPath)) {
|
|
console.error(`Error: File not found: ${csvPath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Importing ${csvPath} into ${collection}...`);
|
|
console.log('');
|
|
|
|
const content = fs.readFileSync(csvPath, 'utf-8');
|
|
const records = parseCSV(content);
|
|
|
|
console.log(`Found ${records.length} records`);
|
|
console.log('');
|
|
|
|
// Show first record for verification
|
|
if (records.length > 0) {
|
|
console.log('Sample record (first row):');
|
|
console.log(JSON.stringify(records[0], null, 2));
|
|
console.log('');
|
|
|
|
const mapped = mapFields(records[0], collection);
|
|
console.log('Mapped to Directus:');
|
|
console.log(JSON.stringify(mapped, null, 2));
|
|
console.log('');
|
|
}
|
|
|
|
// Ask for confirmation
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
});
|
|
|
|
await new Promise(resolve => {
|
|
rl.question(`Import ${records.length} records into '${collection}'? (y/N) `, (answer) => {
|
|
rl.close();
|
|
if (answer.toLowerCase() !== 'y') {
|
|
console.log('Aborted');
|
|
process.exit(0);
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
// Import records
|
|
let success = 0;
|
|
let failed = 0;
|
|
|
|
for (let i = 0; i < records.length; i++) {
|
|
const record = records[i];
|
|
const mapped = mapFields(record, collection);
|
|
|
|
// Remove fields that need special handling
|
|
delete mapped.series_name;
|
|
delete mapped.tag_names;
|
|
delete mapped.image_url;
|
|
|
|
try {
|
|
const result = await createItem(collection, mapped);
|
|
console.log(`[${i + 1}/${records.length}] Created: ${mapped.title || mapped.name || result.id}`);
|
|
success++;
|
|
} catch (err) {
|
|
console.error(`[${i + 1}/${records.length}] Failed: ${err.message}`);
|
|
failed++;
|
|
}
|
|
|
|
// Small delay to avoid rate limiting
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
console.log('');
|
|
console.log(`Import complete: ${success} succeeded, ${failed} failed`);
|
|
}
|
|
|
|
main().catch(console.error);
|