@adminforth/markdown 1.0.0 → 1.2.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.
@@ -5,7 +5,7 @@ set -x
5
5
  COMMIT_SHORT_SHA=$(echo $CI_COMMIT_SHA | cut -c1-8)
6
6
 
7
7
 
8
- if [ "$CI_STEP_STATUS" = "success" ]; then
8
+ if [ "$CI_PREV_PIPELINE_STATUS" = "success" ]; then
9
9
  MESSAGE="Did a build without issues on \`$CI_REPO_NAME/$CI_COMMIT_BRANCH\`. Commit: _${CI_COMMIT_MESSAGE}_ (<$CI_COMMIT_URL|$COMMIT_SHORT_SHA>)"
10
10
 
11
11
  curl -s -X POST -H "Content-Type: application/json" -d '{
@@ -15,8 +15,6 @@ steps:
15
15
  from_secret: VAULT_TOKEN
16
16
  commands:
17
17
  - infisical export --domain https://vault.devforth.io/api --format=dotenv-export --env="prod" > /woodpecker/deploy.vault.env
18
- secrets:
19
- - VAULT_TOKEN
20
18
 
21
19
  release:
22
20
  image: node:20
@@ -36,7 +34,6 @@ steps:
36
34
  when:
37
35
  - event: push
38
36
  status: [failure, success]
39
- - event: push
40
37
  image: curlimages/curl
41
38
  commands:
42
39
  - export $(cat /woodpecker/deploy.vault.env | xargs)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Devforth.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # AdminForth Markdown Plugin
2
+
3
+ <img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /> <img src="https://woodpecker.devforth.io/api/badges/3848/status.svg" alt="Build Status" /> <a href="https://www.npmjs.com/package/@adminforth/markdown"> <img src="https://img.shields.io/npm/dt/@adminforth/markdown" alt="npm downloads" /> </a> <a href="https://www.npmjs.com/package/@adminforth/markdown"> <img src="https://img.shields.io/npm/v/@adminforth/markdown" alt="npm version" /> </a>
4
+
5
+ Enables powerful Markdown editing capabilities in AdminForth applications using <a href="https://milkdown.dev">Milkdown</a>.
6
+
7
+ ## For usage, see [AdminForth Markdown Documentation](https://adminforth.dev/docs/tutorial/Plugins/markdown/)
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);
@@ -37,10 +39,34 @@ export default class MarkdownPlugin extends AdminForthPlugin {
37
39
  if (!column) {
38
40
  throw new Error(`Column ${fieldName} not found in resource ${resourceConfig.label}`);
39
41
  }
40
-
41
42
  if (!column.components) {
42
43
  column.components = {};
43
44
  }
45
+ if (this.options.attachments) {
46
+ const resource = await adminforth.config.resources.find(r => r.resourceId === this.options.attachments!.attachmentResource);
47
+ if (!resource) {
48
+ throw new Error(`Resource '${this.options.attachments!.attachmentResource}' not found`);
49
+ }
50
+ this.attachmentResource = resource;
51
+ const field = await resource.columns.find(c => c.name === this.options.attachments!.attachmentFieldName);
52
+ if (!field) {
53
+ throw new Error(`Field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
54
+ }
55
+
56
+ const plugin = await adminforth.activatedPlugins.find(p =>
57
+ p.resourceConfig!.resourceId === this.options.attachments!.attachmentResource &&
58
+ p.pluginOptions.pathColumnName === this.options.attachments!.attachmentFieldName
59
+ );
60
+ if (!plugin) {
61
+ 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}`);
62
+ }
63
+
64
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
65
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' in resource '${this.options.attachments!.attachmentResource}'
66
+ 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)`);
67
+ }
68
+ this.uploadPlugin = plugin;
69
+ }
44
70
 
45
71
  column.components.show = {
46
72
  file: this.componentPath("MarkdownRenderer.vue"),
@@ -64,6 +90,7 @@ export default class MarkdownPlugin extends AdminForthPlugin {
64
90
  pluginInstanceId: this.pluginInstanceId,
65
91
  columnName: fieldName,
66
92
  pluginType: 'crepe',
93
+ uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
67
94
  },
68
95
  };
69
96
 
@@ -73,7 +100,138 @@ export default class MarkdownPlugin extends AdminForthPlugin {
73
100
  pluginInstanceId: this.pluginInstanceId,
74
101
  columnName: fieldName,
75
102
  pluginType: 'crepe',
103
+ uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
76
104
  },
77
105
  };
106
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
107
+ if (this.options.attachments) {
108
+
109
+ function getAttachmentPathes(markdown: string): string[] {
110
+ if (!markdown) {
111
+ return [];
112
+ }
113
+
114
+ const s3PathRegex = /!\[.*?\]\((https:\/\/.*?\/.*?)(\?.*)?\)/g;
115
+
116
+ const matches = [...markdown.matchAll(s3PathRegex)];
117
+
118
+ return matches
119
+ .map(match => match[1])
120
+ .filter(src => src.includes("s3") || src.includes("amazonaws"));
121
+ }
122
+
123
+ const createAttachmentRecords = async (
124
+ adminforth: IAdminForth, options: PluginOptions, recordId: any, s3Paths: string[], adminUser: AdminUser
125
+ ) => {
126
+ const extractKey = (s3Paths: string) => s3Paths.replace(/^https:\/\/[^\/]+\/+/, '');
127
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId))
128
+ try {
129
+ await Promise.all(s3Paths.map(async (s3Path) => {
130
+ console.log('Processing path:', s3Path);
131
+ try {
132
+ await adminforth.createResourceRecord(
133
+ {
134
+ resource: this.attachmentResource,
135
+ record: {
136
+ [options.attachments.attachmentFieldName]: extractKey(s3Path),
137
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
138
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
139
+ },
140
+ adminUser
141
+ }
142
+ );
143
+ console.log('Successfully created record for:', s3Path);
144
+ } catch (err) {
145
+ console.error('Error creating record for', s3Path, err);
146
+ }
147
+ }));
148
+ } catch (err) {
149
+ console.error('Error in Promise.all', err);
150
+ }
151
+ }
152
+
153
+ const deleteAttachmentRecords = async (
154
+ adminforth: IAdminForth, options: PluginOptions, s3Paths: string[], adminUser: AdminUser
155
+ ) => {
156
+ if (!s3Paths.length) {
157
+ return;
158
+ }
159
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
160
+ const attachments = await adminforth.resource(options.attachments.attachmentResource).list(
161
+ Filters.IN(options.attachments.attachmentFieldName, s3Paths)
162
+ );
163
+ await Promise.all(attachments.map(async (a: any) => {
164
+ await adminforth.deleteResourceRecord(
165
+ {
166
+ resource: this.attachmentResource,
167
+ recordId: a[attachmentPrimaryKeyField.name],
168
+ adminUser,
169
+ record: a,
170
+ }
171
+ )
172
+ }))
173
+ }
174
+
175
+ (resourceConfig.hooks.create.afterSave).push(async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
176
+ // find all s3Paths in the html
177
+ const s3Paths = getAttachmentPathes(record[this.options.fieldName])
178
+
179
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
180
+ // create attachment records
181
+ await createAttachmentRecords(
182
+ adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
183
+
184
+ return { ok: true };
185
+ });
186
+
187
+ // after edit we need to delete attachments that are not in the html anymore
188
+ // and add new ones
189
+ (resourceConfig.hooks.edit.afterSave).push(
190
+ async ({ recordId, record, adminUser }: { recordId: any, record: any, adminUser: AdminUser }) => {
191
+ process.env.HEAVY_DEBUG && console.log('⚓ Cought hook', recordId, 'rec', record);
192
+ if (record[this.options.fieldName] === undefined) {
193
+ console.log('⚓ Cought hook', recordId, 'rec', record);
194
+ // field was not changed, do nothing
195
+ return { ok: true };
196
+ }
197
+ const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list([
198
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
199
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
200
+ ]);
201
+ const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
202
+ const newS3Paths = getAttachmentPathes(record[this.options.fieldName]);
203
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths)
204
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
205
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
206
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
207
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete)
208
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
209
+ await Promise.all([
210
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
211
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
212
+ ]);
213
+
214
+ return { ok: true };
215
+
216
+ }
217
+ );
218
+
219
+ // after delete we need to delete all attachments
220
+ (resourceConfig.hooks.delete.afterSave).push(
221
+ async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
222
+ const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list(
223
+ [
224
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
225
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
226
+ ]
227
+ );
228
+ const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
229
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
230
+ await deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
231
+
232
+ return { ok: true };
233
+ }
234
+ );
235
+ }
78
236
  }
79
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/markdown",
3
- "version": "1.0.0",
3
+ "version": "1.2.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
  }