@adminforth/markdown 1.0.0 → 1.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/build.log CHANGED
@@ -10,5 +10,5 @@ custom/package-lock.json
10
10
  custom/package.json
11
11
  custom/tsconfig.json
12
12
 
13
- sent 169,522 bytes received 115 bytes 339,274.00 bytes/sec
14
- total size is 169,062 speedup is 1.00
13
+ sent 172,084 bytes received 115 bytes 344,398.00 bytes/sec
14
+ total size is 171,624 speedup is 1.00
@@ -9,18 +9,20 @@
9
9
  </template>
10
10
 
11
11
  <script setup lang="ts">
12
- import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
13
-
14
- import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core';
15
- import { gfm } from '@milkdown/kit/preset/gfm';
16
- import { commonmark } from '@milkdown/preset-commonmark';
17
- import { listener, listenerCtx } from '@milkdown/plugin-listener';
12
+ import { ref, onMounted, onBeforeUnmount } from 'vue';
13
+ import { callAdminForthApi } from '@/utils';
14
+ import { Editor } from '@milkdown/core';
18
15
  import { Crepe } from '@milkdown/crepe';
19
-
16
+ import type { AdminForthColumn } from '@/types/Common';
20
17
  import '@milkdown/crepe/theme/common/style.css';
21
18
  import '@milkdown/crepe/theme/frame-dark.css';
22
19
 
23
- const props = defineProps<{ column: any; record: any }>();
20
+ const props = defineProps<{
21
+ column: AdminForthColumn,
22
+ record: any,
23
+ meta: any,
24
+ }>()
25
+
24
26
  const emit = defineEmits(['update:value']);
25
27
  const editorContainer = ref<HTMLElement | null>(null);
26
28
  const content = ref(props.record[props.column.name] || '');
@@ -63,32 +65,120 @@ onMounted(async () => {
63
65
  // Crepe
64
66
  if (props.column.components.edit.meta.pluginType === 'crepe' || props.column.components.create.meta.pluginType === 'crepe') {
65
67
  crepeInstance = await new Crepe({
66
- root: editorContainer.value,
67
- defaultValue: content.value,
68
- });
69
-
70
- crepeInstance.on((listener) => {
71
- listener.markdownUpdated(() => {
72
- const markdownContent = crepeInstance.getMarkdown();
73
- emit('update:value', markdownContent);
68
+ root: editorContainer.value,
69
+ defaultValue: content.value,
74
70
  });
75
71
 
76
- listener.focus(() => {
77
- isFocused.value = true;
78
- });
79
- listener.blur(() => {
80
- isFocused.value = false;
81
- });
82
- });
72
+ crepeInstance.on((listener) => {
73
+ listener.markdownUpdated(async () => {
74
+ let markdownContent = crepeInstance.getMarkdown();
75
+ markdownContent = await replaceBlobsWithS3Urls(markdownContent);
76
+ emit('update:value', markdownContent);
77
+ });
78
+
79
+ listener.focus(() => {
80
+ isFocused.value = true;
81
+ });
82
+ listener.blur(() => {
83
+ isFocused.value = false;
84
+ });
85
+ });
83
86
 
84
- await crepeInstance.create();
85
- console.log('Crepe editor created');
87
+ await crepeInstance.create();
88
+ console.log('Crepe editor created');
86
89
  }
87
90
  } catch (error) {
88
91
  console.error('Failed to initialize editor:', error);
89
92
  }
90
93
  });
91
94
 
95
+ async function replaceBlobsWithS3Urls(markdownContent: string): Promise<string> {
96
+ const blobUrls = markdownContent.match(/blob:[^\s)]+/g);
97
+ const base64Images = markdownContent.match(/data:image\/[^;]+;base64,[^\s)]+/g);
98
+ if (blobUrls) {
99
+ for (let blobUrl of blobUrls) {
100
+ const file = await getFileFromBlobUrl(blobUrl);
101
+ if (file) {
102
+ const s3Url = await uploadFileToS3(file);
103
+ if (s3Url) {
104
+ markdownContent = markdownContent.replace(blobUrl, s3Url);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ if (base64Images) {
110
+ for (let base64Image of base64Images) {
111
+ const file = await fetch(base64Image).then(res => res.blob()).then(blob => new File([blob], 'image.jpg', { type: blob.type }));
112
+ if (file) {
113
+ const s3Url = await uploadFileToS3(file);
114
+ if (s3Url) {
115
+ markdownContent = markdownContent.replace(base64Image, s3Url);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return markdownContent;
121
+ }
122
+
123
+ async function getFileFromBlobUrl(blobUrl: string): Promise<File | null> {
124
+ try {
125
+ const response = await fetch(blobUrl);
126
+ const blob = await response.blob();
127
+ const file = new File([blob], 'uploaded-image.jpg', { type: blob.type });
128
+ return file;
129
+ } catch (error) {
130
+ console.error('Failed to get file from blob URL:', error);
131
+ return null;
132
+ }
133
+ }
134
+ async function uploadFileToS3(file: File) {
135
+ if (!file || !file.name) {
136
+ console.error('File or file name is undefined');
137
+ return;
138
+ }
139
+
140
+ const formData = new FormData();
141
+ formData.append('image', file);
142
+ const originalFilename = file.name.split('.').slice(0, -1).join('.');
143
+ const originalExtension = file.name.split('.').pop();
144
+
145
+ const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
146
+ path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
147
+ method: 'POST',
148
+ body: {
149
+ originalFilename,
150
+ contentType: file.type,
151
+ size: file.size,
152
+ originalExtension,
153
+ },
154
+ });
155
+
156
+ if (error) {
157
+ console.error('Upload failed:', error);
158
+ return;
159
+ }
160
+
161
+ const xhr = new XMLHttpRequest();
162
+ xhr.open('PUT', uploadUrl, true);
163
+ xhr.setRequestHeader('Content-Type', file.type);
164
+ xhr.setRequestHeader('x-amz-tagging', tagline);
165
+ xhr.send(file);
166
+
167
+ return new Promise((resolve, reject) => {
168
+ xhr.onload = () => {
169
+ if (xhr.status === 200) {
170
+ resolve(previewUrl);
171
+ } else {
172
+ reject('Error uploading to S3');
173
+ }
174
+ };
175
+
176
+ xhr.onerror = () => {
177
+ reject('Error uploading to S3');
178
+ };
179
+ });
180
+ }
181
+
92
182
  onBeforeUnmount(() => {
93
183
  milkdownInstance?.destroy();
94
184
  crepeInstance?.destroy();
@@ -9,18 +9,20 @@
9
9
  </template>
10
10
 
11
11
  <script setup lang="ts">
12
- import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
13
-
14
- import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core';
15
- import { gfm } from '@milkdown/kit/preset/gfm';
16
- import { commonmark } from '@milkdown/preset-commonmark';
17
- import { listener, listenerCtx } from '@milkdown/plugin-listener';
12
+ import { ref, onMounted, onBeforeUnmount } from 'vue';
13
+ import { callAdminForthApi } from '@/utils';
14
+ import { Editor } from '@milkdown/core';
18
15
  import { Crepe } from '@milkdown/crepe';
19
-
16
+ import type { AdminForthColumn } from '@/types/Common';
20
17
  import '@milkdown/crepe/theme/common/style.css';
21
18
  import '@milkdown/crepe/theme/frame-dark.css';
22
19
 
23
- const props = defineProps<{ column: any; record: any }>();
20
+ const props = defineProps<{
21
+ column: AdminForthColumn,
22
+ record: any,
23
+ meta: any,
24
+ }>()
25
+
24
26
  const emit = defineEmits(['update:value']);
25
27
  const editorContainer = ref<HTMLElement | null>(null);
26
28
  const content = ref(props.record[props.column.name] || '');
@@ -63,32 +65,120 @@ onMounted(async () => {
63
65
  // Crepe
64
66
  if (props.column.components.edit.meta.pluginType === 'crepe' || props.column.components.create.meta.pluginType === 'crepe') {
65
67
  crepeInstance = await new Crepe({
66
- root: editorContainer.value,
67
- defaultValue: content.value,
68
- });
69
-
70
- crepeInstance.on((listener) => {
71
- listener.markdownUpdated(() => {
72
- const markdownContent = crepeInstance.getMarkdown();
73
- emit('update:value', markdownContent);
68
+ root: editorContainer.value,
69
+ defaultValue: content.value,
74
70
  });
75
71
 
76
- listener.focus(() => {
77
- isFocused.value = true;
78
- });
79
- listener.blur(() => {
80
- isFocused.value = false;
81
- });
82
- });
72
+ crepeInstance.on((listener) => {
73
+ listener.markdownUpdated(async () => {
74
+ let markdownContent = crepeInstance.getMarkdown();
75
+ markdownContent = await replaceBlobsWithS3Urls(markdownContent);
76
+ emit('update:value', markdownContent);
77
+ });
78
+
79
+ listener.focus(() => {
80
+ isFocused.value = true;
81
+ });
82
+ listener.blur(() => {
83
+ isFocused.value = false;
84
+ });
85
+ });
83
86
 
84
- await crepeInstance.create();
85
- console.log('Crepe editor created');
87
+ await crepeInstance.create();
88
+ console.log('Crepe editor created');
86
89
  }
87
90
  } catch (error) {
88
91
  console.error('Failed to initialize editor:', error);
89
92
  }
90
93
  });
91
94
 
95
+ async function replaceBlobsWithS3Urls(markdownContent: string): Promise<string> {
96
+ const blobUrls = markdownContent.match(/blob:[^\s)]+/g);
97
+ const base64Images = markdownContent.match(/data:image\/[^;]+;base64,[^\s)]+/g);
98
+ if (blobUrls) {
99
+ for (let blobUrl of blobUrls) {
100
+ const file = await getFileFromBlobUrl(blobUrl);
101
+ if (file) {
102
+ const s3Url = await uploadFileToS3(file);
103
+ if (s3Url) {
104
+ markdownContent = markdownContent.replace(blobUrl, s3Url);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ if (base64Images) {
110
+ for (let base64Image of base64Images) {
111
+ const file = await fetch(base64Image).then(res => res.blob()).then(blob => new File([blob], 'image.jpg', { type: blob.type }));
112
+ if (file) {
113
+ const s3Url = await uploadFileToS3(file);
114
+ if (s3Url) {
115
+ markdownContent = markdownContent.replace(base64Image, s3Url);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return markdownContent;
121
+ }
122
+
123
+ async function getFileFromBlobUrl(blobUrl: string): Promise<File | null> {
124
+ try {
125
+ const response = await fetch(blobUrl);
126
+ const blob = await response.blob();
127
+ const file = new File([blob], 'uploaded-image.jpg', { type: blob.type });
128
+ return file;
129
+ } catch (error) {
130
+ console.error('Failed to get file from blob URL:', error);
131
+ return null;
132
+ }
133
+ }
134
+ async function uploadFileToS3(file: File) {
135
+ if (!file || !file.name) {
136
+ console.error('File or file name is undefined');
137
+ return;
138
+ }
139
+
140
+ const formData = new FormData();
141
+ formData.append('image', file);
142
+ const originalFilename = file.name.split('.').slice(0, -1).join('.');
143
+ const originalExtension = file.name.split('.').pop();
144
+
145
+ const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
146
+ path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
147
+ method: 'POST',
148
+ body: {
149
+ originalFilename,
150
+ contentType: file.type,
151
+ size: file.size,
152
+ originalExtension,
153
+ },
154
+ });
155
+
156
+ if (error) {
157
+ console.error('Upload failed:', error);
158
+ return;
159
+ }
160
+
161
+ const xhr = new XMLHttpRequest();
162
+ xhr.open('PUT', uploadUrl, true);
163
+ xhr.setRequestHeader('Content-Type', file.type);
164
+ xhr.setRequestHeader('x-amz-tagging', tagline);
165
+ xhr.send(file);
166
+
167
+ return new Promise((resolve, reject) => {
168
+ xhr.onload = () => {
169
+ if (xhr.status === 200) {
170
+ resolve(previewUrl);
171
+ } else {
172
+ reject('Error uploading to S3');
173
+ }
174
+ };
175
+
176
+ xhr.onerror = () => {
177
+ reject('Error uploading to S3');
178
+ };
179
+ });
180
+ }
181
+
92
182
  onBeforeUnmount(() => {
93
183
  milkdownInstance?.destroy();
94
184
  crepeInstance?.destroy();
package/dist/index.js CHANGED
@@ -7,10 +7,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { AdminForthPlugin } from "adminforth";
10
+ import { AdminForthPlugin, Filters } from "adminforth";
11
11
  export default class MarkdownPlugin extends AdminForthPlugin {
12
12
  constructor(options) {
13
13
  super(options, import.meta.url);
14
+ this.attachmentResource = undefined;
14
15
  this.options = options;
15
16
  }
16
17
  instanceUniqueRepresentation(pluginOptions) {
@@ -32,6 +33,7 @@ export default class MarkdownPlugin extends AdminForthPlugin {
32
33
  modifyResourceConfig: { get: () => super.modifyResourceConfig }
33
34
  });
34
35
  return __awaiter(this, void 0, void 0, function* () {
36
+ var _a, _b;
35
37
  _super.modifyResourceConfig.call(this, adminforth, resourceConfig);
36
38
  this.resourceConfig = resourceConfig;
37
39
  const fieldName = this.options.fieldName;
@@ -42,6 +44,27 @@ export default class MarkdownPlugin extends AdminForthPlugin {
42
44
  if (!column.components) {
43
45
  column.components = {};
44
46
  }
47
+ if (this.options.attachments) {
48
+ const resource = yield adminforth.config.resources.find(r => r.resourceId === this.options.attachments.attachmentResource);
49
+ if (!resource) {
50
+ throw new Error(`Resource '${this.options.attachments.attachmentResource}' not found`);
51
+ }
52
+ this.attachmentResource = resource;
53
+ const field = yield resource.columns.find(c => c.name === this.options.attachments.attachmentFieldName);
54
+ if (!field) {
55
+ throw new Error(`Field '${this.options.attachments.attachmentFieldName}' not found in resource '${this.options.attachments.attachmentResource}'`);
56
+ }
57
+ const plugin = yield adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.options.attachments.attachmentResource &&
58
+ p.pluginOptions.pathColumnName === this.options.attachments.attachmentFieldName);
59
+ if (!plugin) {
60
+ throw new Error(`${plugin} 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}`);
61
+ }
62
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
63
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments.attachmentFieldName}' in resource '${this.options.attachments.attachmentResource}'
64
+ 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)`);
65
+ }
66
+ this.uploadPlugin = plugin;
67
+ }
45
68
  column.components.show = {
46
69
  file: this.componentPath("MarkdownRenderer.vue"),
47
70
  meta: {
@@ -62,6 +85,7 @@ export default class MarkdownPlugin extends AdminForthPlugin {
62
85
  pluginInstanceId: this.pluginInstanceId,
63
86
  columnName: fieldName,
64
87
  pluginType: 'crepe',
88
+ uploadPluginInstanceId: (_a = this.uploadPlugin) === null || _a === void 0 ? void 0 : _a.pluginInstanceId,
65
89
  },
66
90
  };
67
91
  column.components.create = {
@@ -70,8 +94,110 @@ export default class MarkdownPlugin extends AdminForthPlugin {
70
94
  pluginInstanceId: this.pluginInstanceId,
71
95
  columnName: fieldName,
72
96
  pluginType: 'crepe',
97
+ uploadPluginInstanceId: (_b = this.uploadPlugin) === null || _b === void 0 ? void 0 : _b.pluginInstanceId,
73
98
  },
74
99
  };
100
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
101
+ if (this.options.attachments) {
102
+ function getAttachmentPathes(markdown) {
103
+ if (!markdown) {
104
+ return [];
105
+ }
106
+ const s3PathRegex = /!\[.*?\]\((https:\/\/.*?\/.*?)(\?.*)?\)/g;
107
+ const matches = [...markdown.matchAll(s3PathRegex)];
108
+ return matches
109
+ .map(match => match[1])
110
+ .filter(src => src.includes("s3") || src.includes("amazonaws"));
111
+ }
112
+ const createAttachmentRecords = (adminforth, options, recordId, s3Paths, adminUser) => __awaiter(this, void 0, void 0, function* () {
113
+ const extractKey = (s3Paths) => s3Paths.replace(/^https:\/\/[^\/]+\/+/, '');
114
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId));
115
+ try {
116
+ yield Promise.all(s3Paths.map((s3Path) => __awaiter(this, void 0, void 0, function* () {
117
+ console.log('Processing path:', s3Path);
118
+ try {
119
+ yield adminforth.createResourceRecord({
120
+ resource: this.attachmentResource,
121
+ record: {
122
+ [options.attachments.attachmentFieldName]: extractKey(s3Path),
123
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
124
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
125
+ },
126
+ adminUser
127
+ });
128
+ console.log('Successfully created record for:', s3Path);
129
+ }
130
+ catch (err) {
131
+ console.error('Error creating record for', s3Path, err);
132
+ }
133
+ })));
134
+ }
135
+ catch (err) {
136
+ console.error('Error in Promise.all', err);
137
+ }
138
+ });
139
+ const deleteAttachmentRecords = (adminforth, options, s3Paths, adminUser) => __awaiter(this, void 0, void 0, function* () {
140
+ if (!s3Paths.length) {
141
+ return;
142
+ }
143
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
144
+ const attachments = yield adminforth.resource(options.attachments.attachmentResource).list(Filters.IN(options.attachments.attachmentFieldName, s3Paths));
145
+ yield Promise.all(attachments.map((a) => __awaiter(this, void 0, void 0, function* () {
146
+ yield adminforth.deleteResourceRecord({
147
+ resource: this.attachmentResource,
148
+ recordId: a[attachmentPrimaryKeyField.name],
149
+ adminUser,
150
+ record: a,
151
+ });
152
+ })));
153
+ });
154
+ (resourceConfig.hooks.create.afterSave).push((_a) => __awaiter(this, [_a], void 0, function* ({ record, adminUser }) {
155
+ // find all s3Paths in the html
156
+ const s3Paths = getAttachmentPathes(record[this.options.fieldName]);
157
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
158
+ // create attachment records
159
+ yield createAttachmentRecords(adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
160
+ return { ok: true };
161
+ }));
162
+ // after edit we need to delete attachments that are not in the html anymore
163
+ // and add new ones
164
+ (resourceConfig.hooks.edit.afterSave).push((_a) => __awaiter(this, [_a], void 0, function* ({ recordId, record, adminUser }) {
165
+ process.env.HEAVY_DEBUG && console.log('⚓ Cought hook', recordId, 'rec', record);
166
+ if (record[this.options.fieldName] === undefined) {
167
+ console.log('⚓ Cought hook', recordId, 'rec', record);
168
+ // field was not changed, do nothing
169
+ return { ok: true };
170
+ }
171
+ const existingAparts = yield adminforth.resource(this.options.attachments.attachmentResource).list([
172
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
173
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
174
+ ]);
175
+ const existingS3Paths = existingAparts.map((a) => a[this.options.attachments.attachmentFieldName]);
176
+ const newS3Paths = getAttachmentPathes(record[this.options.fieldName]);
177
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths);
178
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
179
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
180
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
181
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete);
182
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
183
+ yield Promise.all([
184
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
185
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
186
+ ]);
187
+ return { ok: true };
188
+ }));
189
+ // after delete we need to delete all attachments
190
+ (resourceConfig.hooks.delete.afterSave).push((_a) => __awaiter(this, [_a], void 0, function* ({ record, adminUser }) {
191
+ const existingAparts = yield adminforth.resource(this.options.attachments.attachmentResource).list([
192
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
193
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
194
+ ]);
195
+ const existingS3Paths = existingAparts.map((a) => a[this.options.attachments.attachmentFieldName]);
196
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
197
+ yield deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
198
+ return { ok: true };
199
+ }));
200
+ }
75
201
  });
76
202
  }
77
203
  }
package/index.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { AdminForthPlugin, AdminForthResource, IAdminForth } from "adminforth";
1
+ import { AdminForthPlugin, AdminForthResource, IAdminForth, Filters, AdminUser} from "adminforth";
2
2
  import { PluginOptions } from "./types.js";
3
3
 
4
4
  export default class MarkdownPlugin extends AdminForthPlugin {
5
5
  options: PluginOptions;
6
6
  resourceConfig!: AdminForthResource;
7
7
  adminforth!: IAdminForth;
8
+ uploadPlugin: AdminForthPlugin;
9
+ attachmentResource: AdminForthResource = undefined;
8
10
 
9
11
  constructor(options: PluginOptions) {
10
12
  super(options, import.meta.url);
@@ -41,6 +43,31 @@ export default class MarkdownPlugin extends AdminForthPlugin {
41
43
  if (!column.components) {
42
44
  column.components = {};
43
45
  }
46
+ if (this.options.attachments) {
47
+ const resource = await adminforth.config.resources.find(r => r.resourceId === this.options.attachments!.attachmentResource);
48
+ if (!resource) {
49
+ throw new Error(`Resource '${this.options.attachments!.attachmentResource}' not found`);
50
+ }
51
+ this.attachmentResource = resource;
52
+ const field = await resource.columns.find(c => c.name === this.options.attachments!.attachmentFieldName);
53
+ if (!field) {
54
+ throw new Error(`Field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
55
+ }
56
+
57
+ const plugin = await adminforth.activatedPlugins.find(p =>
58
+ p.resourceConfig!.resourceId === this.options.attachments!.attachmentResource &&
59
+ p.pluginOptions.pathColumnName === this.options.attachments!.attachmentFieldName
60
+ );
61
+ if (!plugin) {
62
+ throw new Error(`${plugin} 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}`);
63
+ }
64
+
65
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
66
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' in resource '${this.options.attachments!.attachmentResource}'
67
+ 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)`);
68
+ }
69
+ this.uploadPlugin = plugin;
70
+ }
44
71
 
45
72
  column.components.show = {
46
73
  file: this.componentPath("MarkdownRenderer.vue"),
@@ -64,6 +91,7 @@ export default class MarkdownPlugin extends AdminForthPlugin {
64
91
  pluginInstanceId: this.pluginInstanceId,
65
92
  columnName: fieldName,
66
93
  pluginType: 'crepe',
94
+ uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
67
95
  },
68
96
  };
69
97
 
@@ -73,7 +101,138 @@ export default class MarkdownPlugin extends AdminForthPlugin {
73
101
  pluginInstanceId: this.pluginInstanceId,
74
102
  columnName: fieldName,
75
103
  pluginType: 'crepe',
104
+ uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
76
105
  },
77
106
  };
107
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
108
+ if (this.options.attachments) {
109
+
110
+ function getAttachmentPathes(markdown: string): string[] {
111
+ if (!markdown) {
112
+ return [];
113
+ }
114
+
115
+ const s3PathRegex = /!\[.*?\]\((https:\/\/.*?\/.*?)(\?.*)?\)/g;
116
+
117
+ const matches = [...markdown.matchAll(s3PathRegex)];
118
+
119
+ return matches
120
+ .map(match => match[1])
121
+ .filter(src => src.includes("s3") || src.includes("amazonaws"));
122
+ }
123
+
124
+ const createAttachmentRecords = async (
125
+ adminforth: IAdminForth, options: PluginOptions, recordId: any, s3Paths: string[], adminUser: AdminUser
126
+ ) => {
127
+ const extractKey = (s3Paths: string) => s3Paths.replace(/^https:\/\/[^\/]+\/+/, '');
128
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId))
129
+ try {
130
+ await Promise.all(s3Paths.map(async (s3Path) => {
131
+ console.log('Processing path:', s3Path);
132
+ try {
133
+ await adminforth.createResourceRecord(
134
+ {
135
+ resource: this.attachmentResource,
136
+ record: {
137
+ [options.attachments.attachmentFieldName]: extractKey(s3Path),
138
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
139
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
140
+ },
141
+ adminUser
142
+ }
143
+ );
144
+ console.log('Successfully created record for:', s3Path);
145
+ } catch (err) {
146
+ console.error('Error creating record for', s3Path, err);
147
+ }
148
+ }));
149
+ } catch (err) {
150
+ console.error('Error in Promise.all', err);
151
+ }
152
+ }
153
+
154
+ const deleteAttachmentRecords = async (
155
+ adminforth: IAdminForth, options: PluginOptions, s3Paths: string[], adminUser: AdminUser
156
+ ) => {
157
+ if (!s3Paths.length) {
158
+ return;
159
+ }
160
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
161
+ const attachments = await adminforth.resource(options.attachments.attachmentResource).list(
162
+ Filters.IN(options.attachments.attachmentFieldName, s3Paths)
163
+ );
164
+ await Promise.all(attachments.map(async (a: any) => {
165
+ await adminforth.deleteResourceRecord(
166
+ {
167
+ resource: this.attachmentResource,
168
+ recordId: a[attachmentPrimaryKeyField.name],
169
+ adminUser,
170
+ record: a,
171
+ }
172
+ )
173
+ }))
174
+ }
175
+
176
+ (resourceConfig.hooks.create.afterSave).push(async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
177
+ // find all s3Paths in the html
178
+ const s3Paths = getAttachmentPathes(record[this.options.fieldName])
179
+
180
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
181
+ // create attachment records
182
+ await createAttachmentRecords(
183
+ adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
184
+
185
+ return { ok: true };
186
+ });
187
+
188
+ // after edit we need to delete attachments that are not in the html anymore
189
+ // and add new ones
190
+ (resourceConfig.hooks.edit.afterSave).push(
191
+ async ({ recordId, record, adminUser }: { recordId: any, record: any, adminUser: AdminUser }) => {
192
+ process.env.HEAVY_DEBUG && console.log('⚓ Cought hook', recordId, 'rec', record);
193
+ if (record[this.options.fieldName] === undefined) {
194
+ console.log('⚓ Cought hook', recordId, 'rec', record);
195
+ // field was not changed, do nothing
196
+ return { ok: true };
197
+ }
198
+ const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list([
199
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
200
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
201
+ ]);
202
+ const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
203
+ const newS3Paths = getAttachmentPathes(record[this.options.fieldName]);
204
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths)
205
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
206
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
207
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
208
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete)
209
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
210
+ await Promise.all([
211
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
212
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
213
+ ]);
214
+
215
+ return { ok: true };
216
+
217
+ }
218
+ );
219
+
220
+ // after delete we need to delete all attachments
221
+ (resourceConfig.hooks.delete.afterSave).push(
222
+ async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
223
+ const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list(
224
+ [
225
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
226
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
227
+ ]
228
+ );
229
+ const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
230
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
231
+ await deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
232
+
233
+ return { ok: true };
234
+ }
235
+ );
236
+ }
78
237
  }
79
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/markdown",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Markdown plugin for adminforth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/types.ts CHANGED
@@ -1,4 +1,39 @@
1
1
 
2
2
  export interface PluginOptions {
3
3
  fieldName: string;
4
+
5
+ /**
6
+ * Allows to attach images to the HTML text
7
+ * Requires to have a separate resource with Upload Plugin installed on attachment field.
8
+ * Each attachment used in HTML will create one record in the attachment resource.
9
+ */
10
+ attachments?: {
11
+ /**
12
+ * Resource name where images are stored. Should point to the existing resource.
13
+ */
14
+ attachmentResource: string;
15
+
16
+ /**
17
+ * Field name in the attachment resource where image is stored. Should point to the existing field in the attachment resource.
18
+ * Also there should be upload plugin installed on this field.
19
+ */
20
+ attachmentFieldName: string; // e.g. 'image_path',
21
+
22
+ /**
23
+ * When attachment is created, it will be linked to the record, by storing id of the record with editor in attachment resource.
24
+ * Here you define the field name where this id will be stored.
25
+ *
26
+ * Linking is needed to remove all attachments when record is deleted.
27
+ *
28
+ * For example when RichEditor installed on description field of apartment resource,
29
+ * field in attachment resource described hear will store id of apartment record.
30
+ */
31
+ attachmentRecordIdFieldName: string; // e.g. 'apartment_id',
32
+
33
+ /**
34
+ * When attachment is created, it will be linked to the resource, by storing id of the resource with editor in attachment resource.
35
+ * For example when RichEditor installed on description field of apartment resource, it will store id of apartment resource.
36
+ */
37
+ attachmentResourceIdFieldName: string; // e.g. 'apartment_resource_id',
38
+ },
4
39
  }