Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { watchCommand } from '../src/commands/watch.js';
import { newCommand } from '../src/commands/new.js';
import { uploadCommand } from '../src/commands/upload.js';
import { forcePushCommand } from '../src/commands/force-push.js';
import { deleteCommand } from '../src/commands/delete.js';
import { checkForUpdates, getCurrentVersion } from '../src/utils/version-check.js';

// Check for updates in background
Expand Down Expand Up @@ -85,4 +86,10 @@ program
.option('--dry-run', 'Show what would be pushed without making changes', false)
.action(forcePushCommand);

program
.command('delete <file>')
.description('Delete a WordPress item and mark the local file as .deleted')
.option('-d, --dir <directory>', 'Content directory (default: current directory)')
.action(deleteCommand);

program.parse();
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 125 additions & 0 deletions src/commands/delete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { rename, access } from 'fs/promises';
import { join, basename } from 'path';
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig, CONTENT_TYPES, loadState, saveState, resolveContentDir } from '../config.js';
import { WordPressClient } from '../api/wordpress.js';

export async function deleteCommand(file, options) {
const dir = options.dir;
const config = await loadConfig(dir);
if (!config) {
console.log(chalk.red('No configuration found. Run `wp-md init` first.'));
return;
}

const client = new WordPressClient(config);
const state = await loadState(dir);
const contentDir = resolveContentDir(dir);

// Resolve the file path relative to contentDir if not absolute
let filepath;
if (file.startsWith('/')) {
filepath = file;
} else {
filepath = join(contentDir, file);
}

// Find matching state entry by filepath
let relativePath = null;
let stateEntry = null;

for (const [relPath, entry] of Object.entries(state.files)) {
const absPath = join(contentDir, relPath);
if (absPath === filepath || relPath === file) {
relativePath = relPath;
stateEntry = entry;
break;
}
}

if (!stateEntry) {
// Try to match by basename only when the input has no directory separator
const fileBasename = basename(file);
if (!file.includes('/') && !file.includes('\\')) {
for (const [relPath, entry] of Object.entries(state.files)) {
if (basename(relPath) === fileBasename) {
relativePath = relPath;
stateEntry = entry;
filepath = join(contentDir, relPath);
console.log(chalk.dim(` Matched by filename: ${relPath}`));
break;
}
}
}
}

if (!stateEntry) {
console.log(chalk.red(`No tracked file found for: ${file}`));
console.log(chalk.dim('Make sure the file has been pulled from WordPress and is tracked in state.'));
return;
}

const { id, type } = stateEntry;

if (!id) {
console.log(chalk.red(`No WordPress ID found in state for: ${relativePath}`));
return;
}

const typeConfig = CONTENT_TYPES[type];
const typeLabel = typeConfig ? typeConfig.label : type;

console.log(chalk.bold(`\n🗑️ Deleting from WordPress\n`));
console.log(` File: ${relativePath}`);
console.log(` Type: ${typeLabel}`);
console.log(` ID: ${id}\n`);

// Verify the local file still exists (may already be .deleted)
let localFileExists = false;
try {
await access(filepath);
localFileExists = true;
} catch {
// Check if already renamed to .deleted
try {
await access(filepath + '.deleted');
console.log(chalk.yellow(`Local file already marked as deleted: ${relativePath}.deleted`));
} catch {
console.log(chalk.yellow(`Local file not found: ${relativePath}`));
}
}

const spinner = ora(`Deleting ${typeLabel} (ID: ${id}) from WordPress...`).start();

try {
await client.delete(type, id);
spinner.succeed(`Deleted from WordPress (ID: ${id})`);
} catch (error) {
spinner.fail(`Failed to delete from WordPress: ${error.message}`);
return;
}

// Rename local file to .deleted
if (localFileExists) {
const deletedPath = filepath + '.deleted';
try {
await rename(filepath, deletedPath);
console.log(chalk.green(`✓ Renamed local file: ${relativePath} → ${basename(relativePath)}.deleted`));
} catch (error) {
console.log(chalk.yellow(`Warning: Could not rename local file: ${error.message}`));
}
}

// Update state: mark as deleted (remove the active entry)
delete state.files[relativePath];
state.files[relativePath + '.deleted'] = {
...stateEntry,
deleted: true,
deletedAt: new Date().toISOString(),
};

await saveState(state, dir);

console.log(chalk.bold('\n✅ Done\n'));
}
76 changes: 74 additions & 2 deletions src/commands/pull.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from 'fs/promises';
import { mkdir, writeFile, rename, access } from 'fs/promises';
import { join } from 'path';
import chalk from 'chalk';
import ora from 'ora';
Expand Down Expand Up @@ -27,6 +27,11 @@ export async function pullCommand(options) {
let totalFiles = 0;
let updatedFiles = 0;
let newFiles = 0;
let deletedFiles = 0;

// Track which remote IDs were seen per type to detect deletions
const pulledTypes = new Set();
const remoteIds = {}; // type -> Set<id>

for (const type of typesToPull) {
const typeConfig = CONTENT_TYPES[type];
Expand All @@ -44,6 +49,7 @@ export async function pullCommand(options) {
if (result.isNew) newFiles += result.filesCreated;
else if (result.isChanged) updatedFiles++;
totalFiles += result.filesCreated;
pulledTypes.add(type);
spinner.succeed(`${typeConfig.label}: ${result.filesCreated} files (${result.theme})`);
continue;
}
Expand All @@ -54,6 +60,10 @@ export async function pullCommand(options) {
newFiles += result.newFiles;
updatedFiles += result.updatedFiles;
totalFiles += result.totalFiles;
if (result.remoteIds) {
remoteIds[type] = result.remoteIds;
pulledTypes.add(type);
}
spinner.succeed(`${typeConfig.label}: ${result.totalFiles} items (${result.variableCount} variable)`);
continue;
}
Expand All @@ -64,6 +74,9 @@ export async function pullCommand(options) {
const typeDir = join(contentDir, typeConfig.folder);
await mkdir(typeDir, { recursive: true });

remoteIds[type] = new Set(items.map(i => i.id));
pulledTypes.add(type);

for (const item of items) {
const filename = generateFilename(item);
const filepath = join(typeDir, filename);
Expand Down Expand Up @@ -127,6 +140,9 @@ export async function pullCommand(options) {
const taxDir = join(contentDir, taxConfig.folder);
await mkdir(taxDir, { recursive: true });

remoteIds[taxonomy] = new Set(items.map(i => i.id));
pulledTypes.add(taxonomy);

for (const item of items) {
const filename = `${item.slug}.md`;
const filepath = join(taxDir, filename);
Expand Down Expand Up @@ -163,17 +179,70 @@ export async function pullCommand(options) {
}
}

// Detect remote deletions: any tracked file whose type was pulled but whose
// ID is no longer present in WordPress should be marked as deleted locally.
const deleted = await markRemotelyDeletedFiles(contentDir, state, pulledTypes, remoteIds);
deletedFiles = deleted;

state.lastSync = new Date().toISOString();
await saveState(state, dir);

console.log(chalk.bold('\n📊 Summary'));
console.log(` Total: ${totalFiles} files`);
console.log(` New: ${chalk.green(newFiles)}`);
console.log(` Updated: ${chalk.yellow(updatedFiles)}`);
console.log(` Unchanged: ${chalk.dim(totalFiles - newFiles - updatedFiles)}`);
if (deletedFiles > 0) {
console.log(` Deleted remotely: ${chalk.red(deletedFiles)} (marked as .deleted locally)`);
}
console.log(` Unchanged: ${chalk.dim(totalFiles - newFiles - updatedFiles - deletedFiles)}`);
console.log('');
}

/**
* For each tracked file whose type was fully pulled, check if its WordPress ID
* still appears in the remote set. If not, rename the local file to .deleted.
*/
async function markRemotelyDeletedFiles(contentDir, state, pulledTypes, remoteIds) {
let count = 0;

for (const [relativePath, entry] of Object.entries(state.files)) {
// Skip already-deleted entries and special/untracked types
if (entry.deleted) continue;
if (!entry.type || !entry.id) continue;
if (!pulledTypes.has(entry.type)) continue;

// wp_global_styles entries don't have simple ID-based deletion tracking
if (entry.type === 'wp_global_styles') continue;

const ids = remoteIds[entry.type];
if (!ids || ids.has(entry.id)) continue;

// This item no longer exists in WordPress — mark local file as deleted
const filepath = join(contentDir, relativePath);
const deletedPath = filepath + '.deleted';

try {
await access(filepath);
await rename(filepath, deletedPath);
console.log(chalk.red(` Deleted remotely: ${relativePath} → ${relativePath}.deleted`));
} catch {
// Local file may already be missing or renamed; still update state
}

// Update state
delete state.files[relativePath];
state.files[relativePath + '.deleted'] = {
...entry,
deleted: true,
deletedAt: new Date().toISOString(),
};

count++;
}

return count;
}

async function pullGlobalStyles(client, contentDir, state, force) {
const globalStyles = await client.fetchGlobalStyles();

Expand Down Expand Up @@ -236,6 +305,7 @@ async function pullWcProducts(client, contentDir, state, force, spinner) {
newFiles: 0,
updatedFiles: 0,
variableCount: 0,
remoteIds: null,
};

// Check if WooCommerce is available
Expand All @@ -252,6 +322,8 @@ async function pullWcProducts(client, contentDir, state, force, spinner) {
spinner.text = 'Fetching products via WooCommerce API...';
const products = await client.fetchWcProducts();

result.remoteIds = new Set(products.map(p => p.id));

for (const product of products) {
spinner.text = `Processing ${product.name}...`;

Expand Down