@paroicms/converter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @paroicms/converter
2
+
3
+ CLI tool to migrate field data from Quill format to Tiptap format in ParoiCMS main databases (SQLite).
4
+
5
+ ## Usage
6
+
7
+ ### Single Database Mode
8
+
9
+ ```bash
10
+ npx @paroicms/converter \
11
+ --database /path/to/main.sqlite \
12
+ --quill-to-tiptap
13
+ ```
14
+
15
+ This will:
16
+
17
+ 1. Run a dry-run pass to detect errors
18
+ 2. Run the actual conversion pass
19
+
20
+ ### Multisite Mode
21
+
22
+ ```bash
23
+ npx @paroicms/converter \
24
+ --multisite-data-dir /path/to/data \
25
+ --quill-to-tiptap
26
+ ```
27
+
28
+ This will process all databases matching the pattern `${dataDir}/*/main.sqlite`:
29
+
30
+ 1. Run a dry-run pass on all databases
31
+ 2. If all databases pass without errors, run the actual conversion on all databases
32
+ 3. If any database has errors in dry-run, abort the entire process
33
+
34
+ ## CLI Options
35
+
36
+ - `--database <path>` (required*): Path to the SQLite database file
37
+ - `--multisite-data-dir <path>` (required*): Path to the multisite data directory
38
+ - `--quill-to-tiptap` (required): Activate Quill to Tiptap conversion
39
+ - `--dry-run` (optional): Run in dry-run mode only (no database changes)
40
+ - `--backup` (optional): Create a backup before conversion
41
+ - `--force` (optional): Skip dry-run pass and convert immediately
42
+
43
+ *You must specify either `--database` or `--multisite-data-dir`, but not both.
44
+
45
+ ## Exit Codes
46
+
47
+ - `0`: All rows converted successfully
48
+ - `1`: One or more rows failed to convert (errors will be logged)
49
+
50
+ ## Error Handling
51
+
52
+ The tool will:
53
+
54
+ - Skip failed rows and continue with others
55
+ - Log all errors with field name, nodeId, and language
56
+ - Return exit code 1 if any errors occurred
57
+ - In multisite mode: abort if any database has errors during dry-run pass
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { CliOptions } from "./types.js";
2
+ export declare function parseCliArguments(args: string[]): CliOptions;
package/dist/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ export function parseCliArguments(args) {
2
+ let database;
3
+ let multisiteDataDir;
4
+ let quillToTiptap = false;
5
+ let dryRun = false;
6
+ let backup = false;
7
+ let force = false;
8
+ for (let i = 0; i < args.length; ++i) {
9
+ const arg = args[i];
10
+ if (arg === "--database") {
11
+ ++i;
12
+ if (i >= args.length) {
13
+ throw new Error("Missing value for --database option");
14
+ }
15
+ database = args[i];
16
+ }
17
+ else if (arg === "--multisite-data-dir") {
18
+ ++i;
19
+ if (i >= args.length) {
20
+ throw new Error("Missing value for --multisite-data-dir option");
21
+ }
22
+ multisiteDataDir = args[i];
23
+ }
24
+ else if (arg === "--quill-to-tiptap") {
25
+ quillToTiptap = true;
26
+ }
27
+ else if (arg === "--dry-run") {
28
+ dryRun = true;
29
+ }
30
+ else if (arg === "--backup") {
31
+ backup = true;
32
+ }
33
+ else if (arg === "--force") {
34
+ force = true;
35
+ }
36
+ else if (!arg.startsWith("--")) {
37
+ }
38
+ else {
39
+ throw new Error(`Unknown option: ${arg}`);
40
+ }
41
+ }
42
+ if (!database && !multisiteDataDir) {
43
+ throw new Error("Missing required option: --database or --multisite-data-dir");
44
+ }
45
+ if (database && multisiteDataDir) {
46
+ throw new Error("Cannot use both --database and --multisite-data-dir options");
47
+ }
48
+ if (!quillToTiptap) {
49
+ throw new Error("Missing required flag: --quill-to-tiptap");
50
+ }
51
+ if (database) {
52
+ const options = {
53
+ kind: "singleDatabase",
54
+ database,
55
+ quillToTiptap,
56
+ dryRun,
57
+ backup,
58
+ force,
59
+ };
60
+ return options;
61
+ }
62
+ if (multisiteDataDir) {
63
+ const options = {
64
+ kind: "multisite",
65
+ multisiteDataDir,
66
+ quillToTiptap,
67
+ dryRun,
68
+ backup,
69
+ force,
70
+ };
71
+ return options;
72
+ }
73
+ throw new Error("Invalid CLI options");
74
+ }
@@ -0,0 +1,6 @@
1
+ import type { Knex } from "knex";
2
+ import type { ConversionResult, FieldRow } from "./types.js";
3
+ export declare function createBackup(dbPath: string): Promise<void>;
4
+ export declare function getFieldRows(cn: Knex): Promise<FieldRow[]>;
5
+ export declare function updateRow(cn: Knex, row: FieldRow, tiptapJson: string): Promise<void>;
6
+ export declare function processRows(cn: Knex, rows: FieldRow[], isDryRun: boolean): Promise<ConversionResult>;
@@ -0,0 +1,95 @@
1
+ import { convertQuillDeltaToTiptap } from "@paroicms/quill-delta-to-tiptap-json";
2
+ import { copyFile } from "node:fs/promises";
3
+ export async function createBackup(dbPath) {
4
+ const backupPath = `${dbPath}.bak`;
5
+ await copyFile(dbPath, backupPath);
6
+ console.log(`Backup created: ${backupPath}`);
7
+ }
8
+ export async function getFieldRows(cn) {
9
+ const rows = await cn("PaFieldText")
10
+ .select("field", "nodeId", "language", "val")
11
+ .where("plugin", "@paroicms/quill-editor-plugin");
12
+ return rows;
13
+ }
14
+ function convertRow(row) {
15
+ try {
16
+ const quillDelta = JSON.parse(row.val);
17
+ const converterOptions = {
18
+ customConverters: {
19
+ inlineFormats: {
20
+ size: (value) => ({ type: "textSize", attrs: { value } }),
21
+ obfuscate: (value) => ({ type: "obfuscate", attrs: { asALink: value } }),
22
+ "internal-link-plugin": (value) => ({
23
+ type: "internalLinkPlugin",
24
+ attrs: { documentId: value },
25
+ }),
26
+ underline: (value) => (value ? { type: "italic" } : undefined),
27
+ },
28
+ embeds: {
29
+ media: (value) => {
30
+ const attrs = { ...value };
31
+ if (attrs.zoomable === undefined || attrs.zoomable === true) {
32
+ attrs.zoomable = true;
33
+ }
34
+ if (attrs.zoomable === false) {
35
+ delete attrs.zoomable;
36
+ }
37
+ return { type: "media", attrs };
38
+ },
39
+ "html-snippet": (value) => ({ type: "htmlSnippet", attrs: { html: value } }),
40
+ "video-plugin": (value) => ({ type: "videoPlugin", attrs: { videoId: value } }),
41
+ },
42
+ },
43
+ };
44
+ const conversionResult = convertQuillDeltaToTiptap(quillDelta, converterOptions);
45
+ const tiptapJson = JSON.stringify(conversionResult.result);
46
+ return { success: true, tiptapJson };
47
+ }
48
+ catch (error) {
49
+ const errorMessage = error instanceof Error ? error.message : String(error);
50
+ return { success: false, error: errorMessage };
51
+ }
52
+ }
53
+ export async function updateRow(cn, row, tiptapJson) {
54
+ await cn("PaFieldText")
55
+ .update({
56
+ val: tiptapJson,
57
+ plugin: "@paroicms/tiptap-editor-plugin",
58
+ })
59
+ .where({
60
+ field: row.field,
61
+ nodeId: row.nodeId,
62
+ language: row.language,
63
+ });
64
+ }
65
+ export async function processRows(cn, rows, isDryRun) {
66
+ const result = {
67
+ totalRows: rows.length,
68
+ converted: 0,
69
+ errors: 0,
70
+ errorDetails: [],
71
+ };
72
+ for (let i = 0; i < rows.length; ++i) {
73
+ const row = rows[i];
74
+ if ((i + 1) % 50 === 0 || i + 1 === rows.length) {
75
+ console.log(`Progress: ${i + 1}/${rows.length}...`);
76
+ }
77
+ const conversionResult = convertRow(row);
78
+ if (conversionResult.success) {
79
+ ++result.converted;
80
+ if (!isDryRun && conversionResult.tiptapJson) {
81
+ await updateRow(cn, row, conversionResult.tiptapJson);
82
+ }
83
+ }
84
+ else {
85
+ ++result.errors;
86
+ result.errorDetails.push({
87
+ field: row.field,
88
+ nodeId: row.nodeId,
89
+ language: row.language,
90
+ error: conversionResult.error ?? "Unknown error",
91
+ });
92
+ }
93
+ }
94
+ return result;
95
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ import knex from "knex";
3
+ import { access } from "node:fs/promises";
4
+ import { parseCliArguments } from "./cli.js";
5
+ import { createBackup, getFieldRows, processRows } from "./converter.js";
6
+ import { createMultisiteResult, discoverDatabases } from "./multisite.js";
7
+ async function processSingleDatabase(dbPath, isDryRun, doBackup) {
8
+ if (doBackup && !isDryRun) {
9
+ await createBackup(dbPath);
10
+ }
11
+ const cn = knex({
12
+ client: "sqlite3",
13
+ connection: {
14
+ filename: dbPath,
15
+ },
16
+ useNullAsDefault: true,
17
+ });
18
+ try {
19
+ const rows = await getFieldRows(cn);
20
+ console.log(`Found ${rows.length} rows to process\n`);
21
+ if (rows.length === 0) {
22
+ return {
23
+ totalRows: 0,
24
+ converted: 0,
25
+ errors: 0,
26
+ errorDetails: [],
27
+ };
28
+ }
29
+ console.log(`Processing ${rows.length} rows...`);
30
+ return await processRows(cn, rows, isDryRun);
31
+ }
32
+ finally {
33
+ await cn.destroy();
34
+ }
35
+ }
36
+ async function runSingleDatabaseMode() {
37
+ const startTime = Date.now();
38
+ console.log("Field Converter: Quill to Tiptap");
39
+ const options = parseCliArguments(process.argv.slice(2));
40
+ if (options.kind !== "singleDatabase") {
41
+ throw new Error("Invalid options kind");
42
+ }
43
+ console.log(`Database: ${options.database}`);
44
+ let exitCode = 0;
45
+ if (!options.force) {
46
+ console.log("Mode: dry-run (first pass)");
47
+ const dryRunResult = await processSingleDatabase(options.database, true, false);
48
+ console.log(`Converted: ${dryRunResult.converted} | Errors: ${dryRunResult.errors}\n`);
49
+ if (dryRunResult.errors > 0) {
50
+ console.log("Errors:");
51
+ for (const errorDetail of dryRunResult.errorDetails) {
52
+ console.log(`- Row [field=${errorDetail.field}, nodeId=${errorDetail.nodeId}, language=${errorDetail.language}]: ${errorDetail.error}`);
53
+ }
54
+ console.log();
55
+ exitCode = 1;
56
+ }
57
+ }
58
+ console.log("Mode: conversion (second pass)");
59
+ const conversionResult = await processSingleDatabase(options.database, false, options.backup ?? false);
60
+ console.log(`Converted: ${conversionResult.converted} | Errors: ${conversionResult.errors}\n`);
61
+ if (conversionResult.errors > 0) {
62
+ console.log("Errors:");
63
+ for (const errorDetail of conversionResult.errorDetails) {
64
+ console.log(`- Row [field=${errorDetail.field}, nodeId=${errorDetail.nodeId}, language=${errorDetail.language}]: ${errorDetail.error}`);
65
+ }
66
+ console.log();
67
+ exitCode = 1;
68
+ }
69
+ const totalTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
70
+ console.log(`Total time: ${totalTimeInSeconds}s\n`);
71
+ if (exitCode === 1) {
72
+ console.log("Exit code: 1 (errors occurred)");
73
+ }
74
+ else {
75
+ console.log("Exit code: 0");
76
+ }
77
+ process.exit(exitCode);
78
+ }
79
+ async function runMultisiteMode() {
80
+ const startTime = Date.now();
81
+ console.log("Field Converter: Quill to Tiptap (Multisite Mode)");
82
+ const options = parseCliArguments(process.argv.slice(2));
83
+ if (options.kind !== "multisite") {
84
+ throw new Error("Invalid options kind");
85
+ }
86
+ console.log(`Multisite data directory: ${options.multisiteDataDir}\n`);
87
+ const databases = await discoverDatabases(options.multisiteDataDir);
88
+ console.log(`Discovered ${databases.length} site(s)\n`);
89
+ if (databases.length === 0) {
90
+ console.log("No databases found. Exit code: 0");
91
+ process.exit(0);
92
+ }
93
+ // Filter databases that exist
94
+ const existingDatabases = [];
95
+ for (const db of databases) {
96
+ try {
97
+ await access(db.dbPath);
98
+ existingDatabases.push(db);
99
+ }
100
+ catch {
101
+ console.log(`⚠️ Skipping ${db.siteName} - main.sqlite not found`);
102
+ }
103
+ }
104
+ if (existingDatabases.length === 0) {
105
+ console.log("No databases found. Exit code: 0");
106
+ process.exit(0);
107
+ }
108
+ console.log(`Processing ${existingDatabases.length} database(s)\n`);
109
+ // First pass: dry-run on all databases (unless force mode)
110
+ if (!options.force) {
111
+ console.log("=".repeat(80));
112
+ console.log("DRY-RUN PASS - Checking all databases");
113
+ console.log("=".repeat(80) + "\n");
114
+ for (const db of existingDatabases) {
115
+ console.log(`\n--- ${db.siteName} ---`);
116
+ console.log(`Database: ${db.dbPath}`);
117
+ db.result = await processSingleDatabase(db.dbPath, true, false);
118
+ console.log(`Converted: ${db.result.converted} | Errors: ${db.result.errors}`);
119
+ if (db.result.errors > 0) {
120
+ console.log("Errors:");
121
+ for (const errorDetail of db.result.errorDetails) {
122
+ console.log(`- Row [field=${errorDetail.field}, nodeId=${errorDetail.nodeId}, language=${errorDetail.language}]: ${errorDetail.error}`);
123
+ }
124
+ }
125
+ }
126
+ const multisiteResult = createMultisiteResult(existingDatabases);
127
+ const totalTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
128
+ console.log(`\n${"=".repeat(80)}`);
129
+ console.log("DRY-RUN SUMMARY");
130
+ console.log("=".repeat(80));
131
+ console.log(`Total databases: ${multisiteResult.totalDatabases}`);
132
+ console.log(`Databases with errors: ${multisiteResult.databasesWithErrors}`);
133
+ console.log(`Successful databases: ${multisiteResult.successfulDatabases}`);
134
+ console.log(`Total time: ${totalTimeInSeconds}s\n`);
135
+ if (multisiteResult.databasesWithErrors > 0) {
136
+ console.log("❌ Errors detected in dry-run pass. Aborting conversion.");
137
+ console.log("Exit code: 1 (errors occurred)");
138
+ process.exit(1);
139
+ }
140
+ if (options.dryRun) {
141
+ console.log("✅ Dry-run completed successfully.");
142
+ console.log("Exit code: 0");
143
+ process.exit(0);
144
+ }
145
+ console.log("✅ Dry-run pass completed with no errors. Proceeding with conversion.\n");
146
+ }
147
+ // Second pass: actual conversion on all databases
148
+ console.log("=".repeat(80));
149
+ console.log("CONVERSION PASS - Converting all databases");
150
+ console.log("=".repeat(80) + "\n");
151
+ for (const db of existingDatabases) {
152
+ console.log(`\n--- ${db.siteName} ---`);
153
+ console.log(`Database: ${db.dbPath}`);
154
+ db.result = await processSingleDatabase(db.dbPath, false, options.backup ?? false);
155
+ console.log(`Converted: ${db.result.converted} | Errors: ${db.result.errors}`);
156
+ if (db.result.errors > 0) {
157
+ console.log("Errors:");
158
+ for (const errorDetail of db.result.errorDetails) {
159
+ console.log(`- Row [field=${errorDetail.field}, nodeId=${errorDetail.nodeId}, language=${errorDetail.language}]: ${errorDetail.error}`);
160
+ }
161
+ }
162
+ }
163
+ const multisiteResult = createMultisiteResult(existingDatabases);
164
+ const totalTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
165
+ console.log("\n" + "=".repeat(80));
166
+ console.log("FINAL SUMMARY");
167
+ console.log("=".repeat(80));
168
+ console.log(`Total databases: ${multisiteResult.totalDatabases}`);
169
+ console.log(`Successful databases: ${multisiteResult.successfulDatabases}`);
170
+ console.log(`Databases with errors: ${multisiteResult.databasesWithErrors}`);
171
+ console.log(`Total time: ${totalTimeInSeconds}s\n`);
172
+ if (multisiteResult.databasesWithErrors > 0) {
173
+ console.log("Exit code: 1 (errors occurred)");
174
+ process.exit(1);
175
+ }
176
+ console.log("Exit code: 0");
177
+ process.exit(0);
178
+ }
179
+ async function main() {
180
+ try {
181
+ const options = parseCliArguments(process.argv.slice(2));
182
+ if (options.kind === "singleDatabase") {
183
+ await runSingleDatabaseMode();
184
+ }
185
+ else {
186
+ await runMultisiteMode();
187
+ }
188
+ }
189
+ catch (error) {
190
+ const errorMessage = error instanceof Error ? error.message : String(error);
191
+ console.error(`Error: ${errorMessage}`);
192
+ process.exit(1);
193
+ }
194
+ }
195
+ main().catch((error) => {
196
+ console.error("Unhandled error:", error);
197
+ process.exit(1);
198
+ });
@@ -0,0 +1,3 @@
1
+ import type { DatabaseResult, MultisiteConversionResult } from "./types.js";
2
+ export declare function discoverDatabases(multisiteDataDir: string): Promise<DatabaseResult[]>;
3
+ export declare function createMultisiteResult(databases: DatabaseResult[]): MultisiteConversionResult;
@@ -0,0 +1,40 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function discoverDatabases(multisiteDataDir) {
4
+ const entries = await readdir(multisiteDataDir, { withFileTypes: true });
5
+ const siteDirs = entries.filter((entry) => entry.isDirectory());
6
+ const databases = [];
7
+ for (const siteDir of siteDirs) {
8
+ const siteName = siteDir.name;
9
+ const dbPath = join(multisiteDataDir, siteName, "main.sqlite");
10
+ databases.push({
11
+ siteName,
12
+ dbPath,
13
+ result: {
14
+ totalRows: 0,
15
+ converted: 0,
16
+ errors: 0,
17
+ errorDetails: [],
18
+ },
19
+ });
20
+ }
21
+ return databases;
22
+ }
23
+ export function createMultisiteResult(databases) {
24
+ let successfulDatabases = 0;
25
+ let databasesWithErrors = 0;
26
+ for (const db of databases) {
27
+ if (db.result.errors === 0 && db.result.totalRows > 0) {
28
+ ++successfulDatabases;
29
+ }
30
+ else if (db.result.errors > 0) {
31
+ ++databasesWithErrors;
32
+ }
33
+ }
34
+ return {
35
+ totalDatabases: databases.length,
36
+ successfulDatabases,
37
+ databasesWithErrors,
38
+ databases,
39
+ };
40
+ }
@@ -0,0 +1,45 @@
1
+ export type CliOptions = SingleDatabaseCliOptions | MultisiteDatabaseCliOptions;
2
+ export interface SingleDatabaseCliOptions {
3
+ kind: "singleDatabase";
4
+ database: string;
5
+ quillToTiptap: boolean;
6
+ dryRun?: boolean;
7
+ backup?: boolean;
8
+ force?: boolean;
9
+ }
10
+ export interface MultisiteDatabaseCliOptions {
11
+ kind: "multisite";
12
+ multisiteDataDir: string;
13
+ quillToTiptap: boolean;
14
+ dryRun?: boolean;
15
+ backup?: boolean;
16
+ force?: boolean;
17
+ }
18
+ export interface ConversionResult {
19
+ totalRows: number;
20
+ converted: number;
21
+ errors: number;
22
+ errorDetails: Array<{
23
+ field: string;
24
+ nodeId: number;
25
+ language: string;
26
+ error: string;
27
+ }>;
28
+ }
29
+ export interface FieldRow {
30
+ field: string;
31
+ nodeId: number;
32
+ language: string;
33
+ val: string;
34
+ }
35
+ export interface DatabaseResult {
36
+ siteName: string;
37
+ dbPath: string;
38
+ result: ConversionResult;
39
+ }
40
+ export interface MultisiteConversionResult {
41
+ totalDatabases: number;
42
+ successfulDatabases: number;
43
+ databasesWithErrors: number;
44
+ databases: DatabaseResult[];
45
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@paroicms/converter",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool to migrate field data from Quill to Tiptap format",
5
+ "type": "module",
6
+ "bin": {
7
+ "paroicms-converter": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "clear": "rimraf dist/*",
12
+ "dev": "tsc --watch --preserveWatchOutput",
13
+ "postbuild": "chmod +x dist/index.js"
14
+ },
15
+ "dependencies": {
16
+ "knex": "~3.1.0",
17
+ "sqlite3": "~5.1.7",
18
+ "@paroicms/quill-delta-to-tiptap-json": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "~24.8.1",
22
+ "rimraf": "~6.0.1",
23
+ "typescript": "~5.9.3"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ]
28
+ }