@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/.woodpecker/buildRelease.sh +13 -0
- package/.woodpecker/buildSlackNotify.sh +44 -0
- package/.woodpecker/release.yml +43 -0
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/build.log +4 -0
- package/custom/async-queue.ts +31 -0
- package/custom/package-lock.json +74 -0
- package/custom/package.json +15 -0
- package/custom/quillEditor.vue +562 -0
- package/custom/tsconfig.json +19 -0
- package/dist/index.js +262 -0
- package/dist/types.js +1 -0
- package/index.ts +340 -0
- package/package.json +55 -0
- package/tsconfig.json +112 -0
- package/types.ts +171 -0
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
|
+
}
|