@adminforth/rich-editor 1.0.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/dist/index.js ADDED
@@ -0,0 +1,262 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { AdminForthPlugin, Filters, RateLimiter } from "adminforth";
11
+ import * as cheerio from 'cheerio';
12
+ // options:
13
+ // attachments: {
14
+ // attachmentResource: 'description_images',
15
+ // attachmentFieldName: 'image_path',
16
+ // attachmentRecordIdFieldName: 'record_id',
17
+ // attachmentResourceIdFieldName: 'resource_id',
18
+ // },
19
+ export default class RichEditorPlugin extends AdminForthPlugin {
20
+ constructor(options) {
21
+ super(options, import.meta.url);
22
+ this.resourceConfig = undefined;
23
+ this.activationOrder = 100000;
24
+ this.attachmentResource = undefined;
25
+ this.options = options;
26
+ }
27
+ modifyResourceConfig(adminforth, resourceConfig) {
28
+ const _super = Object.create(null, {
29
+ modifyResourceConfig: { get: () => super.modifyResourceConfig }
30
+ });
31
+ return __awaiter(this, void 0, void 0, function* () {
32
+ var _a, _b, _c;
33
+ _super.modifyResourceConfig.call(this, adminforth, resourceConfig);
34
+ this.adminforth = adminforth;
35
+ const c = resourceConfig.columns.find(c => c.name === this.options.htmlFieldName);
36
+ if (!c) {
37
+ throw new Error(`Column ${this.options.htmlFieldName} not found in resource ${resourceConfig.label}`);
38
+ }
39
+ if (this.options.attachments) {
40
+ const resource = adminforth.config.resources.find(r => r.resourceId === this.options.attachments.attachmentResource);
41
+ if (!resource) {
42
+ throw new Error(`Resource '${this.options.attachments.attachmentResource}' not found`);
43
+ }
44
+ this.attachmentResource = resource;
45
+ const field = resource.columns.find(c => c.name === this.options.attachments.attachmentFieldName);
46
+ if (!field) {
47
+ throw new Error(`Field '${this.options.attachments.attachmentFieldName}' not found in resource '${this.options.attachments.attachmentResource}'`);
48
+ }
49
+ const plugin = adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.options.attachments.attachmentResource &&
50
+ p.pluginOptions.pathColumnName === this.options.attachments.attachmentFieldName);
51
+ if (!plugin) {
52
+ throw new Error(`Plugin for attachment field '${this.options.attachments.attachmentFieldName}' not found in resource '${this.options.attachments.attachmentResource}', please check if Upload Plugin is installed on the field ${this.options.attachments.attachmentFieldName}`);
53
+ }
54
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
55
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments.attachmentFieldName}' in resource '${this.options.attachments.attachmentResource}'
56
+ should have s3ACL set to 'public-read' (in vast majority of cases signed urls inside of HTML text is not desired behavior, so we did not implement it)`);
57
+ }
58
+ this.uploadPlugin = plugin;
59
+ }
60
+ const filed = {
61
+ file: this.componentPath('quillEditor.vue'),
62
+ meta: {
63
+ pluginInstanceId: this.pluginInstanceId,
64
+ debounceTime: ((_b = (_a = this.options.completion) === null || _a === void 0 ? void 0 : _a.expert) === null || _b === void 0 ? void 0 : _b.debounceTime) || 300,
65
+ shouldComplete: !!this.options.completion,
66
+ uploadPluginInstanceId: (_c = this.uploadPlugin) === null || _c === void 0 ? void 0 : _c.pluginInstanceId,
67
+ }
68
+ };
69
+ if (!c.components) {
70
+ c.components = {};
71
+ }
72
+ c.components.create = filed;
73
+ c.components.edit = filed;
74
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
75
+ // if attachment configured we need a post-save hook to create attachment records for each image data-s3path
76
+ if (this.options.attachments) {
77
+ function getAttachmentPathes(html) {
78
+ if (!html) {
79
+ return [];
80
+ }
81
+ const $ = cheerio.load(html);
82
+ const s3Paths = [];
83
+ $('img[data-s3path]').each((i, el) => {
84
+ const src = $(el).attr('data-s3path');
85
+ s3Paths.push(src);
86
+ });
87
+ return s3Paths;
88
+ }
89
+ const createAttachmentRecords = (adminforth, options, recordId, s3Paths, adminUser) => __awaiter(this, void 0, void 0, function* () {
90
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId));
91
+ yield Promise.all(s3Paths.map((s3Path) => __awaiter(this, void 0, void 0, function* () {
92
+ yield adminforth.createResourceRecord({
93
+ resource: this.attachmentResource,
94
+ record: {
95
+ [options.attachments.attachmentFieldName]: s3Path,
96
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
97
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
98
+ },
99
+ adminUser
100
+ });
101
+ })));
102
+ });
103
+ const deleteAttachmentRecords = (adminforth, options, s3Paths, adminUser) => __awaiter(this, void 0, void 0, function* () {
104
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
105
+ const attachments = yield adminforth.resource(options.attachments.attachmentResource).list(Filters.IN(options.attachments.attachmentFieldName, s3Paths));
106
+ yield Promise.all(attachments.map((a) => __awaiter(this, void 0, void 0, function* () {
107
+ yield adminforth.deleteResourceRecord({
108
+ resource: this.attachmentResource,
109
+ recordId: a[attachmentPrimaryKeyField.name],
110
+ adminUser,
111
+ record: a,
112
+ });
113
+ })));
114
+ });
115
+ (resourceConfig.hooks.create.afterSave).push((_a) => __awaiter(this, [_a], void 0, function* ({ record, adminUser }) {
116
+ // find all s3Paths in the html
117
+ const s3Paths = getAttachmentPathes(record[this.options.htmlFieldName]);
118
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
119
+ // create attachment records
120
+ yield createAttachmentRecords(adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
121
+ return { ok: true };
122
+ }));
123
+ // after edit we need to delete attachments that are not in the html anymore
124
+ // and add new ones
125
+ (resourceConfig.hooks.edit.afterSave).push((_a) => __awaiter(this, [_a], void 0, function* ({ recordId, record, adminUser }) {
126
+ process.env.HEAVY_DEBUG && console.log('âš“ Cought hook', recordId, 'rec', record);
127
+ if (record[this.options.htmlFieldName] === undefined) {
128
+ // field was not changed, do nothing
129
+ return { ok: true };
130
+ }
131
+ const existingAparts = yield adminforth.resource(this.options.attachments.attachmentResource).list([
132
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
133
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
134
+ ]);
135
+ const existingS3Paths = existingAparts.map((a) => a[this.options.attachments.attachmentFieldName]);
136
+ const newS3Paths = getAttachmentPathes(record[this.options.htmlFieldName]);
137
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths);
138
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
139
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
140
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
141
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete);
142
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
143
+ yield Promise.all([
144
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
145
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
146
+ ]);
147
+ return { ok: true };
148
+ }));
149
+ // after delete we need to delete all attachments
150
+ (resourceConfig.hooks.delete.afterSave).push((_a) => __awaiter(this, [_a], void 0, function* ({ record, adminUser }) {
151
+ const existingAparts = yield adminforth.resource(this.options.attachments.attachmentResource).list([
152
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
153
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
154
+ ]);
155
+ const existingS3Paths = existingAparts.map((a) => a[this.options.attachments.attachmentFieldName]);
156
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
157
+ yield deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
158
+ return { ok: true };
159
+ }));
160
+ }
161
+ });
162
+ }
163
+ validateConfigAfterDiscover(adminforth, resourceConfig) {
164
+ var _a, _b;
165
+ this.adminforth = adminforth;
166
+ if ((_a = this.options.completion) === null || _a === void 0 ? void 0 : _a.adapter) {
167
+ (_b = this.options.completion) === null || _b === void 0 ? void 0 : _b.adapter.validate();
168
+ }
169
+ // optional method where you can safely check field types after database discovery was performed
170
+ if (this.options.completion && !this.options.completion.adapter) {
171
+ throw new Error(`Completion adapter is required`);
172
+ }
173
+ }
174
+ instanceUniqueRepresentation(pluginOptions) {
175
+ // optional method to return unique string representation of plugin instance.
176
+ // Needed if plugin can have multiple instances on one resource
177
+ return pluginOptions.htmlFieldName;
178
+ }
179
+ generateRecordContext(record, maxFields, inputFieldLimit, partsCount) {
180
+ // stringify each field
181
+ let fields = Object.entries(record).map(([key, value]) => {
182
+ return [key, JSON.stringify(value)];
183
+ });
184
+ // sort fields by length, higher first
185
+ fields = fields.sort((a, b) => b[1].length - a[1].length);
186
+ // select longest maxFields fields
187
+ fields = fields.slice(0, maxFields);
188
+ const minLimit = '...'.length * partsCount;
189
+ if (inputFieldLimit < minLimit) {
190
+ throw new Error(`inputFieldLimit should be at least ${minLimit}`);
191
+ }
192
+ // for each field, if it is longer than inputFieldLimit, truncate it using next way:
193
+ // split into 5 parts, divide inputFieldLimit by 5, crop each part to this length, join parts using '...'
194
+ fields = fields.map(([key, value]) => {
195
+ if (value.length > inputFieldLimit) {
196
+ const partLength = Math.floor(inputFieldLimit / partsCount) - '...'.length;
197
+ const parts = [];
198
+ for (let i = 0; i < partsCount; i++) {
199
+ parts.push(value.slice(i * partLength, (i + 1) * partLength));
200
+ }
201
+ value = parts.join('...');
202
+ return [key, value];
203
+ }
204
+ return [key, value];
205
+ });
206
+ return JSON.stringify(Object.fromEntries(fields));
207
+ }
208
+ setupEndpoints(server) {
209
+ server.endpoint({
210
+ method: 'POST',
211
+ path: `/plugin/${this.pluginInstanceId}/doComplete`,
212
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers }) {
213
+ var _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
214
+ const { record } = body;
215
+ if ((_b = this.options.completion.rateLimit) === null || _b === void 0 ? void 0 : _b.limit) {
216
+ // rate limit
217
+ const { error } = RateLimiter.checkRateLimit(this.pluginInstanceId, (_c = this.options.completion.rateLimit) === null || _c === void 0 ? void 0 : _c.limit, this.adminforth.auth.getClientIp(headers));
218
+ if (error) {
219
+ return {
220
+ completion: [],
221
+ };
222
+ }
223
+ }
224
+ const recordNoField = Object.assign({}, record);
225
+ delete recordNoField[this.options.htmlFieldName];
226
+ let currentVal = record[this.options.htmlFieldName];
227
+ const promptLimit = ((_d = this.options.completion.expert) === null || _d === void 0 ? void 0 : _d.promptInputLimit) || 500;
228
+ const inputContext = this.generateRecordContext(recordNoField, ((_f = (_e = this.options.completion.expert) === null || _e === void 0 ? void 0 : _e.recordContext) === null || _f === void 0 ? void 0 : _f.maxFields) || 5, ((_h = (_g = this.options.completion.expert) === null || _g === void 0 ? void 0 : _g.recordContext) === null || _h === void 0 ? void 0 : _h.maxFieldLength) || 300, ((_k = (_j = this.options.completion.expert) === null || _j === void 0 ? void 0 : _j.recordContext) === null || _k === void 0 ? void 0 : _k.splitParts) || 5);
229
+ if (currentVal && currentVal.length > promptLimit) {
230
+ currentVal = currentVal.slice(-promptLimit);
231
+ }
232
+ const resLabel = this.resourceConfig.label;
233
+ let content;
234
+ if (currentVal) {
235
+ content = `Continue writing for text/string field "${this.options.htmlFieldName}" in the table "${resLabel}"\n` +
236
+ (Object.keys(recordNoField).length > 0 ? `Record has values for the context: ${inputContext}\n` : '') +
237
+ `Current field value: ${currentVal}\n` +
238
+ "Don't talk to me. Just write text. No quotes. Don't repeat current field value, just write completion\n";
239
+ }
240
+ else {
241
+ content = `Fill text/string field "${this.options.htmlFieldName}" in the table "${resLabel}"\n` +
242
+ (Object.keys(recordNoField).length > 0 ? `Record has values for the context: ${inputContext}\n` : '') +
243
+ "Be short, clear and precise. No quotes. Don't talk to me. Just write text\n";
244
+ }
245
+ process.env.HEAVY_DEBUG && console.log('🪲 OpenAI Prompt 🧠', content);
246
+ const { content: respContent, finishReason } = yield this.options.completion.adapter.complete(content, (_m = (_l = this.options.completion) === null || _l === void 0 ? void 0 : _l.expert) === null || _m === void 0 ? void 0 : _m.stop, (_p = (_o = this.options.completion) === null || _o === void 0 ? void 0 : _o.expert) === null || _p === void 0 ? void 0 : _p.maxTokens);
247
+ const stop = ((_q = this.options.completion.expert) === null || _q === void 0 ? void 0 : _q.stop) || ['.'];
248
+ let suggestion = respContent + (finishReason === 'stop' ? (stop[0] === '.' && stop.length === 1 ? '. ' : '') : '');
249
+ if (suggestion.startsWith(currentVal)) {
250
+ suggestion = suggestion.slice(currentVal.length);
251
+ }
252
+ const wordsList = suggestion.split(' ').map((w, i) => {
253
+ return (i === suggestion.split(' ').length - 1) ? w : w + ' ';
254
+ });
255
+ // remove quotes from start and end
256
+ return {
257
+ completion: wordsList
258
+ };
259
+ })
260
+ });
261
+ }
262
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/index.ts ADDED
@@ -0,0 +1,340 @@
1
+
2
+ import type { IAdminForth, IHttpServer, AdminForthResource, AdminUser } from "adminforth";
3
+ import type { PluginOptions } from './types.js';
4
+ import { AdminForthPlugin, Filters, RateLimiter } from "adminforth";
5
+ import * as cheerio from 'cheerio';
6
+
7
+
8
+ // options:
9
+ // attachments: {
10
+ // attachmentResource: 'description_images',
11
+ // attachmentFieldName: 'image_path',
12
+ // attachmentRecordIdFieldName: 'record_id',
13
+ // attachmentResourceIdFieldName: 'resource_id',
14
+ // },
15
+
16
+ export default class RichEditorPlugin extends AdminForthPlugin {
17
+ options: PluginOptions;
18
+ resourceConfig: AdminForthResource = undefined;
19
+
20
+ uploadPlugin: AdminForthPlugin;
21
+
22
+ activationOrder: number = 100000;
23
+
24
+ attachmentResource: AdminForthResource = undefined;
25
+
26
+ adminforth: IAdminForth;
27
+
28
+ constructor(options: PluginOptions) {
29
+ super(options, import.meta.url);
30
+ this.options = options;
31
+ }
32
+
33
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
34
+ super.modifyResourceConfig(adminforth, resourceConfig);
35
+ this.adminforth = adminforth;
36
+
37
+ const c = resourceConfig.columns.find(c => c.name === this.options.htmlFieldName);
38
+ if (!c) {
39
+ throw new Error(`Column ${this.options.htmlFieldName} not found in resource ${resourceConfig.label}`);
40
+ }
41
+
42
+ if (this.options.attachments) {
43
+ const resource = adminforth.config.resources.find(r => r.resourceId === this.options.attachments!.attachmentResource);
44
+ if (!resource) {
45
+ throw new Error(`Resource '${this.options.attachments!.attachmentResource}' not found`);
46
+ }
47
+ this.attachmentResource = resource;
48
+ const field = resource.columns.find(c => c.name === this.options.attachments!.attachmentFieldName);
49
+ if (!field) {
50
+ throw new Error(`Field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
51
+ }
52
+ const plugin = adminforth.activatedPlugins.find(p =>
53
+ p.resourceConfig!.resourceId === this.options.attachments!.attachmentResource &&
54
+ p.pluginOptions.pathColumnName === this.options.attachments!.attachmentFieldName
55
+ );
56
+ if (!plugin) {
57
+ throw new Error(`Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}', please check if Upload Plugin is installed on the field ${this.options.attachments!.attachmentFieldName}`);
58
+ }
59
+
60
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
61
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' in resource '${this.options.attachments!.attachmentResource}'
62
+ should have s3ACL set to 'public-read' (in vast majority of cases signed urls inside of HTML text is not desired behavior, so we did not implement it)`);
63
+ }
64
+ this.uploadPlugin = plugin;
65
+ }
66
+
67
+ const filed = {
68
+ file: this.componentPath('quillEditor.vue'),
69
+ meta: {
70
+ pluginInstanceId: this.pluginInstanceId,
71
+ debounceTime: this.options.completion?.expert?.debounceTime || 300,
72
+ shouldComplete: !!this.options.completion,
73
+ uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
74
+ }
75
+ }
76
+
77
+ if (!c.components) {
78
+ c.components = {};
79
+ }
80
+ c.components.create = filed;
81
+ c.components.edit = filed;
82
+
83
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
84
+
85
+
86
+ // if attachment configured we need a post-save hook to create attachment records for each image data-s3path
87
+ if (this.options.attachments) {
88
+
89
+ function getAttachmentPathes(html: string) {
90
+ if (!html) {
91
+ return [];
92
+ }
93
+ const $ = cheerio.load(html);
94
+ const s3Paths = [];
95
+ $('img[data-s3path]').each((i, el) => {
96
+ const src = $(el).attr('data-s3path');
97
+ s3Paths.push(src);
98
+ });
99
+ return s3Paths;
100
+ }
101
+
102
+ const createAttachmentRecords = async (
103
+ adminforth: IAdminForth, options: PluginOptions, recordId: any, s3Paths: string[], adminUser: AdminUser
104
+ ) => {
105
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId))
106
+ await Promise.all(s3Paths.map(async (s3Path) => {
107
+ await adminforth.createResourceRecord(
108
+ {
109
+ resource: this.attachmentResource,
110
+ record: {
111
+ [options.attachments.attachmentFieldName]: s3Path,
112
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
113
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
114
+ },
115
+ adminUser
116
+ }
117
+ )
118
+ }
119
+ ));
120
+ }
121
+
122
+ const deleteAttachmentRecords = async (
123
+ adminforth: IAdminForth, options: PluginOptions, s3Paths: string[], adminUser: AdminUser
124
+ ) => {
125
+
126
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
127
+
128
+ const attachments = await adminforth.resource(options.attachments.attachmentResource).list(
129
+ Filters.IN(options.attachments.attachmentFieldName, s3Paths)
130
+ );
131
+
132
+ await Promise.all(attachments.map(async (a: any) => {
133
+ await adminforth.deleteResourceRecord(
134
+ {
135
+ resource: this.attachmentResource,
136
+ recordId: a[attachmentPrimaryKeyField.name],
137
+ adminUser,
138
+ record: a,
139
+ }
140
+ )
141
+ }))
142
+ }
143
+
144
+
145
+ (resourceConfig.hooks.create.afterSave).push(async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
146
+ // find all s3Paths in the html
147
+ const s3Paths = getAttachmentPathes(record[this.options.htmlFieldName])
148
+
149
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
150
+
151
+ // create attachment records
152
+ await createAttachmentRecords(
153
+ adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
154
+
155
+ return { ok: true };
156
+ });
157
+
158
+ // after edit we need to delete attachments that are not in the html anymore
159
+ // and add new ones
160
+ (resourceConfig.hooks.edit.afterSave).push(
161
+ async ({ recordId, record, adminUser }: { recordId: any, record: any, adminUser: AdminUser }) => {
162
+ process.env.HEAVY_DEBUG && console.log('âš“ Cought hook', recordId, 'rec', record);
163
+ if (record[this.options.htmlFieldName] === undefined) {
164
+ // field was not changed, do nothing
165
+ return { ok: true };
166
+ }
167
+ const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list([
168
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
169
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
170
+ ]);
171
+ const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
172
+ const newS3Paths = getAttachmentPathes(record[this.options.htmlFieldName]);
173
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths)
174
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
175
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
176
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
177
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete)
178
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
179
+
180
+ await Promise.all([
181
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
182
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
183
+ ]);
184
+
185
+ return { ok: true };
186
+
187
+ }
188
+ );
189
+
190
+ // after delete we need to delete all attachments
191
+ (resourceConfig.hooks.delete.afterSave).push(
192
+ async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
193
+ const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list(
194
+ [
195
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
196
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
197
+ ]
198
+ );
199
+ const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
200
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
201
+ await deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
202
+
203
+ return { ok: true };
204
+ }
205
+ );
206
+ }
207
+ }
208
+
209
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
210
+ this.adminforth = adminforth;
211
+ if (this.options.completion?.adapter) {
212
+ this.options.completion?.adapter.validate();
213
+ }
214
+ // optional method where you can safely check field types after database discovery was performed
215
+ if (this.options.completion && !this.options.completion.adapter) {
216
+ throw new Error(`Completion adapter is required`);
217
+ }
218
+
219
+ }
220
+
221
+ instanceUniqueRepresentation(pluginOptions: any) : string {
222
+ // optional method to return unique string representation of plugin instance.
223
+ // Needed if plugin can have multiple instances on one resource
224
+ return pluginOptions.htmlFieldName;
225
+ }
226
+
227
+ generateRecordContext(record: any, maxFields: number, inputFieldLimit: number, partsCount: number): string {
228
+ // stringify each field
229
+ let fields: [string, string][] = Object.entries(record).map(([key, value]) => {
230
+ return [key, JSON.stringify(value)];
231
+ });
232
+
233
+ // sort fields by length, higher first
234
+ fields = fields.sort((a, b) => b[1].length - a[1].length);
235
+
236
+ // select longest maxFields fields
237
+ fields = fields.slice(0, maxFields);
238
+
239
+ const minLimit = '...'.length * partsCount;
240
+
241
+ if (inputFieldLimit < minLimit) {
242
+ throw new Error(`inputFieldLimit should be at least ${minLimit}`);
243
+ }
244
+
245
+ // for each field, if it is longer than inputFieldLimit, truncate it using next way:
246
+ // split into 5 parts, divide inputFieldLimit by 5, crop each part to this length, join parts using '...'
247
+ fields = fields.map(([key, value]) => {
248
+ if (value.length > inputFieldLimit) {
249
+ const partLength = Math.floor(inputFieldLimit / partsCount) - '...'.length;
250
+ const parts: string[] = [];
251
+ for (let i = 0; i < partsCount; i++) {
252
+ parts.push(value.slice(i * partLength, (i + 1) * partLength));
253
+ }
254
+ value = parts.join('...');
255
+ return [key, value];
256
+ }
257
+ return [key, value];
258
+ });
259
+
260
+ return JSON.stringify(Object.fromEntries(fields));
261
+ }
262
+
263
+ setupEndpoints(server: IHttpServer) {
264
+ server.endpoint({
265
+ method: 'POST',
266
+ path: `/plugin/${this.pluginInstanceId}/doComplete`,
267
+ handler: async ({ body, headers }) => {
268
+ const { record } = body;
269
+
270
+ if (this.options.completion.rateLimit?.limit) {
271
+ // rate limit
272
+ const { error } = RateLimiter.checkRateLimit(
273
+ this.pluginInstanceId,
274
+ this.options.completion.rateLimit?.limit,
275
+ this.adminforth.auth.getClientIp(headers),
276
+ );
277
+ if (error) {
278
+ return {
279
+ completion: [],
280
+ }
281
+ }
282
+ }
283
+
284
+ const recordNoField = {...record};
285
+ delete recordNoField[this.options.htmlFieldName];
286
+ let currentVal = record[this.options.htmlFieldName] as string;
287
+ const promptLimit = this.options.completion.expert?.promptInputLimit || 500;
288
+ const inputContext = this.generateRecordContext(
289
+ recordNoField,
290
+ this.options.completion.expert?.recordContext?.maxFields || 5,
291
+ this.options.completion.expert?.recordContext?.maxFieldLength || 300,
292
+ this.options.completion.expert?.recordContext?.splitParts || 5,
293
+ );
294
+
295
+ if (currentVal && currentVal.length > promptLimit) {
296
+ currentVal = currentVal.slice(-promptLimit);
297
+ }
298
+
299
+ const resLabel = this.resourceConfig!.label;
300
+
301
+ let content;
302
+
303
+ if (currentVal) {
304
+ content = `Continue writing for text/string field "${this.options.htmlFieldName}" in the table "${resLabel}"\n` +
305
+ (Object.keys(recordNoField).length > 0 ? `Record has values for the context: ${inputContext}\n` : '') +
306
+ `Current field value: ${currentVal}\n` +
307
+ "Don't talk to me. Just write text. No quotes. Don't repeat current field value, just write completion\n";
308
+
309
+ } else {
310
+ content = `Fill text/string field "${this.options.htmlFieldName}" in the table "${resLabel}"\n` +
311
+ (Object.keys(recordNoField).length > 0 ? `Record has values for the context: ${inputContext}\n` : '') +
312
+ "Be short, clear and precise. No quotes. Don't talk to me. Just write text\n";
313
+ }
314
+
315
+ process.env.HEAVY_DEBUG && console.log('🪲 OpenAI Prompt 🧠', content);
316
+ const { content: respContent, finishReason } = await this.options.completion.adapter.complete(content, this.options.completion?.expert?.stop, this.options.completion?.expert?.maxTokens);
317
+ const stop = this.options.completion.expert?.stop || ['.'];
318
+ let suggestion = respContent + (
319
+ finishReason === 'stop' ? (
320
+ stop[0] === '.' && stop.length === 1 ? '. ' : ''
321
+ ) : ''
322
+ );
323
+
324
+ if (suggestion.startsWith(currentVal)) {
325
+ suggestion = suggestion.slice(currentVal.length);
326
+ }
327
+ const wordsList = suggestion.split(' ').map((w, i) => {
328
+ return (i === suggestion.split(' ').length - 1) ? w : w + ' ';
329
+ });
330
+
331
+ // remove quotes from start and end
332
+ return {
333
+ completion: wordsList
334
+ };
335
+ }
336
+ });
337
+
338
+ }
339
+
340
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@adminforth/rich-editor",
3
+ "version": "1.0.0",
4
+ "description": "Rich editor plugin for adminforth",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "prepare": "npm link adminforth",
10
+ "build": "tsc"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/devforth/adminforth-rich-editor.git"
15
+ },
16
+ "keywords": [
17
+ "adminforth",
18
+ "rich editor"
19
+ ],
20
+ "author": "devforth",
21
+ "license": "ISC",
22
+ "dependencies": {
23
+ "cheerio": "^1.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.7",
27
+ "semantic-release": "^24.2.1",
28
+ "semantic-release-slack-bot": "^4.0.2",
29
+ "typescript": "^5.7.3"
30
+ },
31
+ "release": {
32
+ "plugins": [
33
+ "@semantic-release/commit-analyzer",
34
+ "@semantic-release/release-notes-generator",
35
+ "@semantic-release/npm",
36
+ "@semantic-release/github",
37
+ [
38
+ "semantic-release-slack-bot",
39
+ {
40
+ "notifyOnSuccess": true,
41
+ "notifyOnFail": true,
42
+ "slackIcon": ":package:",
43
+ "markdownReleaseNotes": true
44
+ }
45
+ ]
46
+ ],
47
+ "branches": [
48
+ "main",
49
+ {
50
+ "name": "next",
51
+ "prerelease": true
52
+ }
53
+ ]
54
+ }
55
+ }