@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 +2 -2
- package/custom/MarkdownEditor.vue +115 -25
- package/dist/custom/MarkdownEditor.vue +115 -25
- package/dist/index.js +127 -1
- package/index.ts +160 -1
- package/package.json +1 -1
- package/types.ts +35 -0
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
|
|
14
|
-
total size is
|
|
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,
|
|
13
|
-
|
|
14
|
-
import { Editor
|
|
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<{
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
13
|
-
|
|
14
|
-
import { Editor
|
|
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<{
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
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
|
}
|