feat: recognize macros
This commit is contained in:
107
src/main/config/macroConfig.ts
Normal file
107
src/main/config/macroConfig.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Shared Macro Configuration
|
||||||
|
*
|
||||||
|
* This file defines the macro validation rules that can be used by both
|
||||||
|
* renderer and main process (e.g., for import analysis validation).
|
||||||
|
*
|
||||||
|
* The full macro implementations (with render functions) live in:
|
||||||
|
* src/renderer/macros/definitions/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MacroConfig {
|
||||||
|
/** The macro name (lowercase) */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable description */
|
||||||
|
description: string;
|
||||||
|
/** Required parameters that must be present */
|
||||||
|
requiredParams?: string[];
|
||||||
|
/** Optional parameter validation function */
|
||||||
|
validate?: (params: Record<string, string>) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of known macro configurations.
|
||||||
|
* Add new macros here to enable validation during import analysis.
|
||||||
|
*/
|
||||||
|
export const macroConfigs: MacroConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'youtube',
|
||||||
|
description: 'Embeds a YouTube video player',
|
||||||
|
requiredParams: ['id'],
|
||||||
|
validate: (params) => {
|
||||||
|
if (!params.id) {
|
||||||
|
return 'YouTube macro requires an "id" parameter (the video ID)';
|
||||||
|
}
|
||||||
|
// Basic validation of YouTube ID format (11 characters)
|
||||||
|
if (!/^[\w-]{11}$/.test(params.id)) {
|
||||||
|
return 'Invalid YouTube video ID format';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gallery',
|
||||||
|
description: 'Renders an image gallery from linked media files',
|
||||||
|
validate: (params) => {
|
||||||
|
if (params.columns) {
|
||||||
|
const cols = parseInt(params.columns, 10);
|
||||||
|
if (isNaN(cols) || cols < 1 || cols > 6) {
|
||||||
|
return 'Gallery columns must be a number between 1 and 6';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a map of macro configs by name for quick lookup.
|
||||||
|
*/
|
||||||
|
export function getMacroConfigMap(): Map<string, MacroConfig> {
|
||||||
|
const map = new Map<string, MacroConfig>();
|
||||||
|
for (const config of macroConfigs) {
|
||||||
|
map.set(config.name.toLowerCase(), config);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate macro parameters against known configurations.
|
||||||
|
*
|
||||||
|
* @param macroName - The macro name
|
||||||
|
* @param params - The macro parameters
|
||||||
|
* @returns Error message if invalid, undefined if valid or macro is unknown
|
||||||
|
*/
|
||||||
|
export function validateMacroParams(
|
||||||
|
macroName: string,
|
||||||
|
params: Record<string, string>
|
||||||
|
): { valid: boolean; error?: string; known: boolean } {
|
||||||
|
const config = getMacroConfigMap().get(macroName.toLowerCase());
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return { valid: false, known: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required parameters
|
||||||
|
if (config.requiredParams) {
|
||||||
|
for (const param of config.requiredParams) {
|
||||||
|
if (!params[param]) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
known: true,
|
||||||
|
error: `Missing required parameter: ${param}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run custom validation if provided
|
||||||
|
if (config.validate) {
|
||||||
|
const error = config.validate(params);
|
||||||
|
if (error) {
|
||||||
|
return { valid: false, known: true, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, known: true };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { getDatabase } from '../database';
|
|||||||
import { posts, media, tags } from '../database/schema';
|
import { posts, media, tags } from '../database/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser';
|
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser';
|
||||||
|
import { getMacroConfigMap, type MacroConfig } from '../config/macroConfig';
|
||||||
|
|
||||||
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
||||||
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing';
|
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing';
|
||||||
@@ -48,6 +49,55 @@ export interface AnalyzedTag {
|
|||||||
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validation status for a macro usage */
|
||||||
|
export type MacroValidationStatus = 'valid' | 'invalid' | 'unknown';
|
||||||
|
|
||||||
|
/** A single unique usage pattern of a macro */
|
||||||
|
export interface MacroUsage {
|
||||||
|
/** The parameters used in this particular usage */
|
||||||
|
params: Record<string, string>;
|
||||||
|
/** How many times this exact parameter combination was used */
|
||||||
|
count: number;
|
||||||
|
/** Whether this usage is valid according to our macro definition */
|
||||||
|
validationStatus: MacroValidationStatus;
|
||||||
|
/** Error message if validation failed */
|
||||||
|
validationError?: string;
|
||||||
|
/** Serialized params for deduplication */
|
||||||
|
paramsKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A discovered macro from the import content */
|
||||||
|
export interface DiscoveredMacro {
|
||||||
|
/** The macro name (lowercase) */
|
||||||
|
name: string;
|
||||||
|
/** Whether this macro maps to an internal definition */
|
||||||
|
mapped: boolean;
|
||||||
|
/** Total number of times this macro appears across all content */
|
||||||
|
totalCount: number;
|
||||||
|
/** Unique usages with different parameters */
|
||||||
|
usages: MacroUsage[];
|
||||||
|
/** Slugs of posts/pages where this macro is used */
|
||||||
|
postSlugs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary of macro analysis */
|
||||||
|
export interface MacroAnalysisSummary {
|
||||||
|
/** Total unique macros discovered */
|
||||||
|
total: number;
|
||||||
|
/** Number of macros that map to internal definitions */
|
||||||
|
mappedCount: number;
|
||||||
|
/** Number of macros that don't map to internal definitions */
|
||||||
|
unmappedCount: number;
|
||||||
|
/** All discovered macros with their usages */
|
||||||
|
discovered: DiscoveredMacro[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal interface for macro definition validation */
|
||||||
|
export interface MacroDefinitionLike {
|
||||||
|
name: string;
|
||||||
|
validate?: (params: Record<string, string>) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportAnalysisReport {
|
export interface ImportAnalysisReport {
|
||||||
sourceFile: string;
|
sourceFile: string;
|
||||||
site: WxrSiteInfo;
|
site: WxrSiteInfo;
|
||||||
@@ -79,27 +129,69 @@ export interface ImportAnalysisReport {
|
|||||||
};
|
};
|
||||||
categories: AnalyzedCategory[];
|
categories: AnalyzedCategory[];
|
||||||
tags: AnalyzedTag[];
|
tags: AnalyzedTag[];
|
||||||
|
macros: MacroAnalysisSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImportAnalysisEngine {
|
export class ImportAnalysisEngine {
|
||||||
private currentProjectId: string = '';
|
private currentProjectId: string = '';
|
||||||
private turndown: TurndownService;
|
private turndown: TurndownService;
|
||||||
|
private macroDefinitions: Map<string, MacroDefinitionLike> = new Map();
|
||||||
|
|
||||||
// Progress callback for reporting analysis steps
|
// Progress callback for reporting analysis steps
|
||||||
onProgress?: (step: string, detail?: string) => void;
|
onProgress?: (step: string, detail?: string) => void;
|
||||||
|
|
||||||
|
// Regex to match WordPress shortcodes: [macroname param="val" param2='val2']
|
||||||
|
// This matches single brackets (NOT double brackets like our internal format)
|
||||||
|
// Uses negative lookbehind (?<!\[) and negative lookahead (?!\]) to exclude [[...]]
|
||||||
|
private static readonly SHORTCODE_REGEX = /(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/g;
|
||||||
|
|
||||||
|
// Regex to extract individual parameters from shortcode
|
||||||
|
private static readonly PARAM_REGEX = /(\w+)=["']([^"']*?)["']/g;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.turndown = new TurndownService({
|
this.turndown = new TurndownService({
|
||||||
headingStyle: 'atx',
|
headingStyle: 'atx',
|
||||||
codeBlockStyle: 'fenced',
|
codeBlockStyle: 'fenced',
|
||||||
bulletListMarker: '-',
|
bulletListMarker: '-',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load macro definitions from shared config
|
||||||
|
this.loadMacroConfigsFromShared();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load macro definitions from the shared macro config.
|
||||||
|
* Called automatically in constructor.
|
||||||
|
*/
|
||||||
|
private loadMacroConfigsFromShared(): void {
|
||||||
|
try {
|
||||||
|
const configs = getMacroConfigMap();
|
||||||
|
// Convert MacroConfig to MacroDefinitionLike
|
||||||
|
for (const [name, config] of configs) {
|
||||||
|
this.macroDefinitions.set(name, {
|
||||||
|
name: config.name,
|
||||||
|
validate: config.validate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Config not available - macros will be marked as unmapped
|
||||||
|
console.warn('Could not load macro configs:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setProjectContext(projectId: string): void {
|
setProjectContext(projectId: string): void {
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set macro definitions for mapping and validation.
|
||||||
|
* This overrides the auto-loaded shared config. Useful for testing.
|
||||||
|
* @param definitions Map of macro name (lowercase) to definition
|
||||||
|
*/
|
||||||
|
setMacroDefinitions(definitions: Map<string, MacroDefinitionLike>): void {
|
||||||
|
this.macroDefinitions = definitions;
|
||||||
|
}
|
||||||
|
|
||||||
async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise<ImportAnalysisReport> {
|
async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise<ImportAnalysisReport> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
@@ -194,6 +286,11 @@ export class ImportAnalysisEngine {
|
|||||||
existsInProject: existingTagNames.has(tag.name.toLowerCase()),
|
existsInProject: existingTagNames.has(tag.name.toLowerCase()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
this.onProgress?.('Discovering macros...');
|
||||||
|
|
||||||
|
// Analyze macros from posts and pages content
|
||||||
|
const macroAnalysis = this.analyzeMacros([...wxrData.posts, ...wxrData.pages]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sourceFile,
|
sourceFile,
|
||||||
site: wxrData.site,
|
site: wxrData.site,
|
||||||
@@ -203,6 +300,7 @@ export class ImportAnalysisEngine {
|
|||||||
media: this.summarizeMediaAnalysis(analyzedMedia),
|
media: this.summarizeMediaAnalysis(analyzedMedia),
|
||||||
categories: analyzedCategories,
|
categories: analyzedCategories,
|
||||||
tags: analyzedTags,
|
tags: analyzedTags,
|
||||||
|
macros: macroAnalysis,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,4 +446,154 @@ export class ImportAnalysisEngine {
|
|||||||
private calculateChecksum(content: string): string {
|
private calculateChecksum(content: string): string {
|
||||||
return crypto.createHash('md5').update(content).digest('hex');
|
return crypto.createHash('md5').update(content).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze macros (WordPress shortcodes) from post/page content.
|
||||||
|
* Discovers all shortcodes, aggregates their usages, and validates against definitions.
|
||||||
|
*/
|
||||||
|
private analyzeMacros(posts: WxrPost[]): MacroAnalysisSummary {
|
||||||
|
// Map of macro name -> discovered macro data
|
||||||
|
const macroMap = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
totalCount: number;
|
||||||
|
usages: Map<string, { params: Record<string, string>; count: number }>;
|
||||||
|
postSlugs: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Process each post/page
|
||||||
|
for (const post of posts) {
|
||||||
|
if (!post.content) continue;
|
||||||
|
|
||||||
|
const shortcodes = this.parseShortcodes(post.content);
|
||||||
|
|
||||||
|
for (const shortcode of shortcodes) {
|
||||||
|
const name = shortcode.name.toLowerCase();
|
||||||
|
|
||||||
|
let macroData = macroMap.get(name);
|
||||||
|
if (!macroData) {
|
||||||
|
macroData = {
|
||||||
|
name,
|
||||||
|
totalCount: 0,
|
||||||
|
usages: new Map(),
|
||||||
|
postSlugs: new Set(),
|
||||||
|
};
|
||||||
|
macroMap.set(name, macroData);
|
||||||
|
}
|
||||||
|
|
||||||
|
macroData.totalCount++;
|
||||||
|
macroData.postSlugs.add(post.slug);
|
||||||
|
|
||||||
|
// Create a key for this parameter combination
|
||||||
|
const paramsKey = this.serializeParams(shortcode.params);
|
||||||
|
const existingUsage = macroData.usages.get(paramsKey);
|
||||||
|
if (existingUsage) {
|
||||||
|
existingUsage.count++;
|
||||||
|
} else {
|
||||||
|
macroData.usages.set(paramsKey, { params: shortcode.params, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to final format with validation
|
||||||
|
const discovered: DiscoveredMacro[] = [];
|
||||||
|
|
||||||
|
for (const macroData of macroMap.values()) {
|
||||||
|
const definition = this.macroDefinitions.get(macroData.name);
|
||||||
|
const mapped = definition !== undefined;
|
||||||
|
|
||||||
|
const usages: MacroUsage[] = [];
|
||||||
|
for (const [paramsKey, usage] of macroData.usages) {
|
||||||
|
let validationStatus: MacroValidationStatus = 'unknown';
|
||||||
|
let validationError: string | undefined;
|
||||||
|
|
||||||
|
if (mapped && definition) {
|
||||||
|
if (definition.validate) {
|
||||||
|
const error = definition.validate(usage.params);
|
||||||
|
if (error) {
|
||||||
|
validationStatus = 'invalid';
|
||||||
|
validationError = error;
|
||||||
|
} else {
|
||||||
|
validationStatus = 'valid';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Macro is mapped but has no validation - consider valid
|
||||||
|
validationStatus = 'valid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usages.push({
|
||||||
|
params: usage.params,
|
||||||
|
count: usage.count,
|
||||||
|
validationStatus,
|
||||||
|
validationError,
|
||||||
|
paramsKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered.push({
|
||||||
|
name: macroData.name,
|
||||||
|
mapped,
|
||||||
|
totalCount: macroData.totalCount,
|
||||||
|
usages,
|
||||||
|
postSlugs: Array.from(macroData.postSlugs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort discovered macros by name
|
||||||
|
discovered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: discovered.length,
|
||||||
|
mappedCount: discovered.filter(m => m.mapped).length,
|
||||||
|
unmappedCount: discovered.filter(m => !m.mapped).length,
|
||||||
|
discovered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse WordPress shortcodes from content.
|
||||||
|
* Returns array of { name, params } for each shortcode found.
|
||||||
|
*/
|
||||||
|
private parseShortcodes(content: string): Array<{ name: string; params: Record<string, string> }> {
|
||||||
|
const shortcodes: Array<{ name: string; params: Record<string, string> }> = [];
|
||||||
|
|
||||||
|
// Reset regex lastIndex
|
||||||
|
ImportAnalysisEngine.SHORTCODE_REGEX.lastIndex = 0;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = ImportAnalysisEngine.SHORTCODE_REGEX.exec(content)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const paramString = match[2] || '';
|
||||||
|
const params = this.parseShortcodeParams(paramString);
|
||||||
|
|
||||||
|
shortcodes.push({ name, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortcodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse parameters from a shortcode parameter string.
|
||||||
|
*/
|
||||||
|
private parseShortcodeParams(paramString: string): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Reset regex lastIndex
|
||||||
|
ImportAnalysisEngine.PARAM_REGEX.lastIndex = 0;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = ImportAnalysisEngine.PARAM_REGEX.exec(paramString)) !== null) {
|
||||||
|
params[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize params to a stable string for deduplication.
|
||||||
|
*/
|
||||||
|
private serializeParams(params: Record<string, string>): string {
|
||||||
|
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
return JSON.stringify(sorted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,3 +666,208 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =================================
|
||||||
|
MACROS SECTION STYLES
|
||||||
|
================================= */
|
||||||
|
|
||||||
|
.macro-summary-counts {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-summary-counts .mapped-count {
|
||||||
|
color: var(--vscode-charts-green, #89d185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-summary-counts .unmapped-count {
|
||||||
|
color: var(--vscode-charts-orange, #cca700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macros-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-item {
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-item.mapped {
|
||||||
|
border-left: 3px solid var(--vscode-charts-green, #89d185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-item.unmapped {
|
||||||
|
border-left: 3px solid var(--vscode-charts-orange, #cca700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-header:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon.small {
|
||||||
|
font-size: 8px;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--vscode-editor-font-family, monospace);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-status-badge.mapped {
|
||||||
|
background: rgba(137, 209, 133, 0.15);
|
||||||
|
color: var(--vscode-charts-green, #89d185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-status-badge.unmapped {
|
||||||
|
background: rgba(204, 167, 0, 0.15);
|
||||||
|
color: var(--vscode-charts-orange, #cca700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usages {
|
||||||
|
padding: 0 12px 12px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usages-header {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--vscode-input-border, rgba(255, 255, 255, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-posts-label {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usages-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usage {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usage.valid {
|
||||||
|
border-left-color: var(--vscode-charts-green, #89d185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usage.invalid {
|
||||||
|
border-left-color: var(--vscode-errorForeground, #f85149);
|
||||||
|
background: rgba(248, 81, 73, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usage.unknown {
|
||||||
|
border-left-color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usage-params {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--vscode-editor-font-family, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-params {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-param {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-key {
|
||||||
|
color: var(--vscode-symbolIcon-propertyForeground, #9cdcfe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-value {
|
||||||
|
color: var(--vscode-symbolIcon-stringForeground, #ce9178);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-usage-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-badge {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-badge.valid {
|
||||||
|
background: rgba(137, 209, 133, 0.2);
|
||||||
|
color: var(--vscode-charts-green, #89d185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-badge.invalid {
|
||||||
|
background: rgba(248, 81, 73, 0.2);
|
||||||
|
color: var(--vscode-errorForeground, #f85149);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-badge.unknown {
|
||||||
|
background: rgba(128, 128, 128, 0.2);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface AnalysisReport {
|
|||||||
media: MediaSection;
|
media: MediaSection;
|
||||||
categories: TaxonomyItem[];
|
categories: TaxonomyItem[];
|
||||||
tags: TaxonomyItem[];
|
tags: TaxonomyItem[];
|
||||||
|
macros: MacroAnalysisSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemSection {
|
interface ItemSection {
|
||||||
@@ -75,6 +76,35 @@ interface TaxonomyItem {
|
|||||||
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validation status for a macro usage */
|
||||||
|
type MacroValidationStatus = 'valid' | 'invalid' | 'unknown';
|
||||||
|
|
||||||
|
/** A single unique usage pattern of a macro */
|
||||||
|
interface MacroUsage {
|
||||||
|
params: Record<string, string>;
|
||||||
|
count: number;
|
||||||
|
validationStatus: MacroValidationStatus;
|
||||||
|
validationError?: string;
|
||||||
|
paramsKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A discovered macro from the import content */
|
||||||
|
interface DiscoveredMacro {
|
||||||
|
name: string;
|
||||||
|
mapped: boolean;
|
||||||
|
totalCount: number;
|
||||||
|
usages: MacroUsage[];
|
||||||
|
postSlugs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary of macro analysis */
|
||||||
|
interface MacroAnalysisSummary {
|
||||||
|
total: number;
|
||||||
|
mappedCount: number;
|
||||||
|
unmappedCount: number;
|
||||||
|
discovered: DiscoveredMacro[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ImportAnalysisViewProps {
|
interface ImportAnalysisViewProps {
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
}
|
}
|
||||||
@@ -368,6 +398,14 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
onMappingUpdated={handleSingleMappingUpdated}
|
onMappingUpdated={handleSingleMappingUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{report.macros && report.macros.total > 0 && (
|
||||||
|
<MacrosSection
|
||||||
|
macros={report.macros}
|
||||||
|
expanded={expandedSections['macros'] ?? false}
|
||||||
|
onToggle={() => toggleSection('macros')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,3 +1053,104 @@ const TaxonomyPill: React.FC<{
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacrosSection - Shows discovered macros from import content
|
||||||
|
* Displays macro names, their different usages (parameters), and validation status
|
||||||
|
*/
|
||||||
|
const MacrosSection: React.FC<{
|
||||||
|
macros: MacroAnalysisSummary;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}> = ({ macros, expanded, onToggle }) => {
|
||||||
|
const [expandedMacros, setExpandedMacros] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleMacro = (name: string) => {
|
||||||
|
setExpandedMacros(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name);
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="import-detail-section">
|
||||||
|
<h3 onClick={onToggle}>
|
||||||
|
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||||
|
Macros ({macros.total})
|
||||||
|
<span className="macro-summary-counts">
|
||||||
|
<span className="mapped-count">{macros.mappedCount} mapped</span>
|
||||||
|
{macros.unmappedCount > 0 && (
|
||||||
|
<span className="unmapped-count">{macros.unmappedCount} unmapped</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{expanded && (
|
||||||
|
<div className="macros-list">
|
||||||
|
{macros.discovered.map(macro => (
|
||||||
|
<div key={macro.name} className={`macro-item ${macro.mapped ? 'mapped' : 'unmapped'}`}>
|
||||||
|
<div className="macro-header" onClick={() => toggleMacro(macro.name)}>
|
||||||
|
<span className={`toggle-icon small ${expandedMacros.has(macro.name) ? 'open' : ''}`}>▶</span>
|
||||||
|
<span className="macro-name">[{macro.name}]</span>
|
||||||
|
<span className={`macro-status-badge ${macro.mapped ? 'mapped' : 'unmapped'}`}>
|
||||||
|
{macro.mapped ? 'Mapped' : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span className="macro-count">{macro.totalCount} uses</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedMacros.has(macro.name) && (
|
||||||
|
<div className="macro-usages">
|
||||||
|
<div className="macro-usages-header">
|
||||||
|
<span className="macro-posts-label">Used in: {macro.postSlugs.slice(0, 5).join(', ')}{macro.postSlugs.length > 5 ? `, +${macro.postSlugs.length - 5} more` : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="macro-usages-list">
|
||||||
|
{macro.usages.map((usage, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`macro-usage ${getValidationClass(usage.validationStatus)}`}
|
||||||
|
title={usage.validationError || ''}
|
||||||
|
>
|
||||||
|
<div className="macro-usage-params">
|
||||||
|
{Object.keys(usage.params).length === 0 ? (
|
||||||
|
<span className="no-params">(no parameters)</span>
|
||||||
|
) : (
|
||||||
|
Object.entries(usage.params).map(([key, value]) => (
|
||||||
|
<span key={key} className="macro-param">
|
||||||
|
<span className="param-key">{key}</span>=<span className="param-value">"{value}"</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="macro-usage-meta">
|
||||||
|
<span className={`validation-badge ${usage.validationStatus}`}>
|
||||||
|
{usage.validationStatus === 'valid' && '✓'}
|
||||||
|
{usage.validationStatus === 'invalid' && '✗'}
|
||||||
|
{usage.validationStatus === 'unknown' && '?'}
|
||||||
|
</span>
|
||||||
|
<span className="usage-count">×{usage.count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getValidationClass(status: MacroValidationStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'valid': return 'valid';
|
||||||
|
case 'invalid': return 'invalid';
|
||||||
|
case 'unknown': return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -506,6 +506,226 @@ describe('ImportAnalysisEngine', () => {
|
|||||||
expect(report.posts.contentDuplicates).toBe(0);
|
expect(report.posts.contentDuplicates).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('analyzeWxr - macro discovery', () => {
|
||||||
|
it('should discover macros from post content using WordPress shortcode format', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '<p>Hello world</p>[youtube id="dQw4w9WgXcQ"]<p>More text</p>',
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
expect(report.macros).toBeDefined();
|
||||||
|
expect(report.macros.discovered).toContainEqual(
|
||||||
|
expect.objectContaining({ name: 'youtube' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should discover macros with multiple parameters', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '[gallery columns="4" caption="My Photos"]',
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const galleryMacro = report.macros.discovered.find(m => m.name === 'gallery');
|
||||||
|
expect(galleryMacro).toBeDefined();
|
||||||
|
expect(galleryMacro!.usages).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { columns: '4', caption: 'My Photos' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should aggregate different usages of the same macro', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [
|
||||||
|
createWxrPost({
|
||||||
|
slug: 'post-1',
|
||||||
|
content: '[youtube id="video1"][youtube id="video2" title="My Video"]',
|
||||||
|
}),
|
||||||
|
createWxrPost({
|
||||||
|
slug: 'post-2',
|
||||||
|
content: '[youtube id="video1"]', // Same as first usage in post-1
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube');
|
||||||
|
expect(youtubeMacro).toBeDefined();
|
||||||
|
// Should have 2 unique usages (video1 and video2)
|
||||||
|
expect(youtubeMacro!.usages.length).toBe(2);
|
||||||
|
expect(youtubeMacro!.totalCount).toBe(3); // 3 total occurrences
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should discover macros from pages as well as posts', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({ content: '[gallery columns="3"]' })],
|
||||||
|
pages: [createWxrPost({ postType: 'page', content: '[youtube id="abc123def4g"]' })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const macroNames = report.macros.discovered.map(m => m.name);
|
||||||
|
expect(macroNames).toContain('gallery');
|
||||||
|
expect(macroNames).toContain('youtube');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark macro as mapped when internal definition exists', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
// Register a mock macro for testing
|
||||||
|
const mockMacros = new Map<string, { name: string; validate?: (params: Record<string, string>) => string | undefined }>();
|
||||||
|
mockMacros.set('youtube', {
|
||||||
|
name: 'youtube',
|
||||||
|
validate: (params) => params.id ? undefined : 'Missing id parameter',
|
||||||
|
});
|
||||||
|
mockMacros.set('gallery', { name: 'gallery' });
|
||||||
|
engine.setMacroDefinitions(mockMacros);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '[youtube id="test123test"][unknown_macro param="val"]',
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube');
|
||||||
|
const unknownMacro = report.macros.discovered.find(m => m.name === 'unknown_macro');
|
||||||
|
|
||||||
|
expect(youtubeMacro?.mapped).toBe(true);
|
||||||
|
expect(unknownMacro?.mapped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate macro parameters against definitions', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const mockMacros = new Map<string, { name: string; validate?: (params: Record<string, string>) => string | undefined }>();
|
||||||
|
mockMacros.set('youtube', {
|
||||||
|
name: 'youtube',
|
||||||
|
validate: (params) => params.id ? undefined : 'Missing id parameter',
|
||||||
|
});
|
||||||
|
engine.setMacroDefinitions(mockMacros);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '[youtube id="validid1234"][youtube]', // One valid, one invalid
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube');
|
||||||
|
expect(youtubeMacro).toBeDefined();
|
||||||
|
|
||||||
|
const validUsage = youtubeMacro!.usages.find(u => u.params.id === 'validid1234');
|
||||||
|
const invalidUsage = youtubeMacro!.usages.find(u => Object.keys(u.params).length === 0);
|
||||||
|
|
||||||
|
expect(validUsage?.validationStatus).toBe('valid');
|
||||||
|
expect(invalidUsage?.validationStatus).toBe('invalid');
|
||||||
|
expect(invalidUsage?.validationError).toBe('Missing id parameter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide summary counts for macros', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const mockMacros = new Map<string, { name: string; validate?: (params: Record<string, string>) => string | undefined }>();
|
||||||
|
mockMacros.set('youtube', { name: 'youtube' });
|
||||||
|
engine.setMacroDefinitions(mockMacros);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '[youtube id="vid1"][gallery][custom_macro]',
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
expect(report.macros.total).toBe(3);
|
||||||
|
expect(report.macros.mappedCount).toBe(1); // Only youtube is mapped
|
||||||
|
expect(report.macros.unmappedCount).toBe(2); // gallery and custom_macro not mapped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track which posts contain each macro', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [
|
||||||
|
createWxrPost({ slug: 'post-a', title: 'Post A', content: '[youtube id="vid1"]' }),
|
||||||
|
createWxrPost({ slug: 'post-b', title: 'Post B', content: '[youtube id="vid2"]' }),
|
||||||
|
createWxrPost({ slug: 'post-c', title: 'Post C', content: '[gallery]' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube');
|
||||||
|
expect(youtubeMacro?.postSlugs).toContain('post-a');
|
||||||
|
expect(youtubeMacro?.postSlugs).toContain('post-b');
|
||||||
|
expect(youtubeMacro?.postSlugs).not.toContain('post-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle self-closing shortcodes', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '[gallery /][youtube id="test" /]',
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
expect(report.macros.discovered.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle shortcodes with single-quoted parameters', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: "[youtube id='singlequoted']",
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube');
|
||||||
|
expect(youtubeMacro?.usages[0].params.id).toBe('singlequoted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect our internal macro format as WordPress shortcodes', async () => {
|
||||||
|
setupDbReturns([], [], []);
|
||||||
|
|
||||||
|
const wxrData = createWxrData({
|
||||||
|
posts: [createWxrPost({
|
||||||
|
content: '[[youtube id="internal"]] and [youtube id="wordpress"]',
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||||
|
|
||||||
|
// Should only find the WordPress shortcode, not our internal one
|
||||||
|
expect(report.macros.discovered.length).toBe(1);
|
||||||
|
const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube');
|
||||||
|
expect(youtubeMacro?.usages[0].params.id).toBe('wordpress');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user