notes-to-strapi-export-article-ai 1.0.119 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.eslintrc +30 -22
  2. package/README.md +98 -143
  3. package/images/img.png +0 -0
  4. package/images/img_1.png +0 -0
  5. package/images/img_10.png +0 -0
  6. package/images/img_11.png +0 -0
  7. package/images/img_12.png +0 -0
  8. package/images/img_13.png +0 -0
  9. package/images/img_2.png +0 -0
  10. package/images/img_3.png +0 -0
  11. package/images/img_4.png +0 -0
  12. package/images/img_5.png +0 -0
  13. package/images/img_6.png +0 -0
  14. package/images/img_7.png +0 -0
  15. package/images/img_8.png +0 -0
  16. package/images/img_9.png +0 -0
  17. package/manifest.json +2 -2
  18. package/package.json +29 -26
  19. package/src/components/APIKeys.ts +219 -0
  20. package/src/components/Configuration.ts +663 -0
  21. package/src/components/Dashboard.ts +184 -0
  22. package/src/components/ImageSelectionModal.ts +58 -0
  23. package/src/components/Routes.ts +279 -0
  24. package/src/constants.ts +22 -61
  25. package/src/main.ts +177 -34
  26. package/src/services/configuration-generator.ts +172 -0
  27. package/src/services/field-analyzer.ts +84 -0
  28. package/src/services/frontmatter.ts +329 -0
  29. package/src/services/strapi-export.ts +436 -0
  30. package/src/settings/UnifiedSettingsTab.ts +206 -0
  31. package/src/types/image.ts +27 -16
  32. package/src/types/index.ts +3 -0
  33. package/src/types/route.ts +51 -0
  34. package/src/types/settings.ts +22 -23
  35. package/src/utils/analyse-file.ts +94 -0
  36. package/src/utils/debounce.ts +34 -0
  37. package/src/utils/image-processor.ts +124 -400
  38. package/src/utils/preview-modal.ts +265 -0
  39. package/src/utils/process-file.ts +122 -0
  40. package/src/utils/strapi-uploader.ts +120 -119
  41. package/src/settings.ts +0 -404
  42. package/src/types/article.ts +0 -8
  43. package/src/utils/openai-generator.ts +0 -139
  44. package/src/utils/validators.ts +0 -8
  45. package/version-bump.mjs +0 -14
  46. package/versions.json +0 -119
@@ -0,0 +1,436 @@
1
+ import { RouteConfig, AnalyzedContent } from '../types'
2
+ import { StrapiExporterSettings } from '../types/settings'
3
+ import { extractFrontMatterAndContent } from '../utils/analyse-file'
4
+ import { App, TFile } from 'obsidian'
5
+ import { uploadImageToStrapi } from '../utils/strapi-uploader'
6
+ import * as yaml from 'js-yaml'
7
+
8
+ export class StrapiExportService {
9
+ constructor(
10
+ private settings: StrapiExporterSettings,
11
+ private app: App,
12
+ private file: TFile
13
+ ) {}
14
+
15
+ private async sendToStrapi(data: any, route: RouteConfig): Promise<void> {
16
+ const url = `${this.settings.strapiUrl}${route.url}`
17
+
18
+ const response = await fetch(url, {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ Authorization: `Bearer ${this.settings.strapiApiToken}`,
23
+ },
24
+ body: JSON.stringify(data),
25
+ })
26
+
27
+ const responseText = await response.text()
28
+ let responseData
29
+
30
+ try {
31
+ responseData = JSON.parse(responseText)
32
+ } catch {
33
+ responseData = responseText
34
+ }
35
+
36
+ if (!response.ok) {
37
+ throw new Error(
38
+ `Strapi API error (${response.status}): ${
39
+ typeof responseData === 'object'
40
+ ? JSON.stringify(responseData)
41
+ : responseData
42
+ }`
43
+ )
44
+ }
45
+ }
46
+
47
+ private validateSettings(): void {
48
+ if (!this.settings.strapiUrl) {
49
+ throw new Error('Strapi URL is not configured')
50
+ }
51
+ if (!this.settings.strapiApiToken) {
52
+ throw new Error('Strapi API token is not configured')
53
+ }
54
+ }
55
+
56
+ private validateRoute(route: RouteConfig): void {
57
+ if (!route.url) {
58
+ throw new Error('Route URL is not configured')
59
+ }
60
+ if (!route.contentField) {
61
+ throw new Error('Content field is not configured')
62
+ }
63
+ if (!route.fieldMappings) {
64
+ throw new Error('Field mappings are not configured')
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Process and handle all images in the content
70
+ * @param content - The content to process
71
+ * @returns Promise<{ content: string; wasModified: boolean }> - Updated content with processed image links and modification status
72
+ */
73
+ private async processContentImages(
74
+ content: string
75
+ ): Promise<{ content: string; wasModified: boolean }> {
76
+ // Regular expressions for both internal and external images
77
+ const internalImageRegex = /!\[\[([^\]]+\.(png|jpe?g|gif|webp))\]\]/g
78
+ const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
79
+
80
+ let processedContent = content
81
+ let wasModified = false
82
+ let match
83
+
84
+ // Process internal images (Obsidian format)
85
+ while ((match = internalImageRegex.exec(content)) !== null) {
86
+ const [fullMatch, imagePath] = match
87
+ const processedUrl = await this.processImage(imagePath, true)
88
+ if (processedUrl) {
89
+ wasModified = true
90
+ processedContent = processedContent.replace(
91
+ fullMatch,
92
+ `![${imagePath}](${processedUrl})`
93
+ )
94
+ }
95
+ }
96
+
97
+ // Process markdown images
98
+ while ((match = markdownImageRegex.exec(content)) !== null) {
99
+ const [fullMatch, altText, imagePath] = match
100
+ const processedUrl = await this.processImage(imagePath, false)
101
+ if (processedUrl) {
102
+ wasModified = true
103
+ processedContent = processedContent.replace(
104
+ fullMatch,
105
+ `![${altText}](${processedUrl})`
106
+ )
107
+ }
108
+ }
109
+
110
+ return { content: processedContent, wasModified }
111
+ }
112
+
113
+ /**
114
+ * Process individual image
115
+ * @param imagePath - Path or URL of the image
116
+ * @param isInternal - Whether the image is internal to Obsidian
117
+ * @returns Promise<string | null> - Processed image URL
118
+ */
119
+ private async processImage(
120
+ imagePath: string,
121
+ isInternal: boolean
122
+ ): Promise<string | null> {
123
+ if (isInternal) {
124
+ return await this.handleInternalImage(imagePath)
125
+ } else {
126
+ return await this.handleExternalImage(imagePath)
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Handle internal Obsidian image
132
+ */
133
+ private async handleInternalImage(imagePath: string): Promise<string | null> {
134
+ const file = this.app.vault.getAbstractFileByPath(imagePath)
135
+ if (!(file instanceof TFile)) {
136
+ return null
137
+ }
138
+
139
+ const uploadedImage = await uploadImageToStrapi(
140
+ file,
141
+ file.name,
142
+ this.settings,
143
+ this.app
144
+ )
145
+
146
+ return uploadedImage?.url || null
147
+ }
148
+
149
+ /**
150
+ * Handle external image URL
151
+ */
152
+ private async handleExternalImage(imageUrl: string): Promise<string | null> {
153
+ // First, check if image already exists in Strapi
154
+ const existingImage = await this.checkExistingImage(imageUrl)
155
+ if (existingImage) {
156
+ return existingImage.data.url
157
+ }
158
+
159
+ // If not, download and upload to Strapi
160
+ const response = await fetch(imageUrl)
161
+ const blob = await response.blob()
162
+ const fileName = this.getFileNameFromUrl(imageUrl)
163
+
164
+ const formData = new FormData()
165
+ formData.append('files', blob, fileName)
166
+
167
+ const uploadResponse = await fetch(
168
+ `${this.settings.strapiUrl}/api/upload`,
169
+ {
170
+ method: 'POST',
171
+ headers: {
172
+ Authorization: `Bearer ${this.settings.strapiApiToken}`,
173
+ },
174
+ body: formData,
175
+ }
176
+ )
177
+
178
+ if (!uploadResponse.ok) {
179
+ throw new Error('Failed to upload image to Strapi')
180
+ }
181
+
182
+ const uploadResult = await uploadResponse.json()
183
+ return uploadResult[0]?.url || null
184
+ }
185
+
186
+ /**
187
+ * Check if image already exists in Strapi
188
+ */
189
+ private async checkExistingImage(
190
+ imageUrl: string
191
+ ): Promise<{ data: { id: number; url: string } } | null> {
192
+ const response = await fetch(
193
+ `${this.settings.strapiUrl}/api/upload/files?filters[url][$eq]=${encodeURIComponent(imageUrl)}`,
194
+ {
195
+ headers: {
196
+ Authorization: `Bearer ${this.settings.strapiApiToken}`,
197
+ },
198
+ }
199
+ )
200
+
201
+ if (!response.ok) {
202
+ return null
203
+ }
204
+
205
+ const results = await response.json()
206
+ return results.length > 0
207
+ ? {
208
+ data: {
209
+ id: results[0].id,
210
+ url: results[0].url,
211
+ },
212
+ }
213
+ : null
214
+ }
215
+
216
+ /**
217
+ * Extract filename from URL
218
+ */
219
+ private getFileNameFromUrl(url: string): string {
220
+ const urlParts = url.split('/')
221
+ const fileName = urlParts[urlParts.length - 1].split('?')[0]
222
+ return fileName || 'image.jpg'
223
+ }
224
+
225
+ /**
226
+ * Process frontmatter data and handle any image fields
227
+ * @param frontmatter - The frontmatter object to process
228
+ * @returns Promise<{frontmatter: any, wasModified: boolean}> - Processed frontmatter and modification status
229
+ */
230
+ private async processFrontmatterImages(
231
+ frontmatter: any
232
+ ): Promise<{ frontmatter: any; wasModified: boolean }> {
233
+ let wasModified = false
234
+ const processedFrontmatter = { ...frontmatter }
235
+
236
+ /**
237
+ * Recursively process object properties
238
+ * @param obj - Object to process
239
+ * @returns Promise<any> - Processed object
240
+ */
241
+ const processObject = async (obj: any): Promise<any> => {
242
+ for (const key in obj) {
243
+ const value = obj[key]
244
+
245
+ // Handle arrays
246
+ if (Array.isArray(value)) {
247
+ const processedArray = await Promise.all(
248
+ value.map(async item => {
249
+ if (typeof item === 'object' && item !== null) {
250
+ return await processObject(item)
251
+ }
252
+ if (typeof item === 'string') {
253
+ return await processStringValue(item)
254
+ }
255
+ return item
256
+ })
257
+ )
258
+
259
+ if (JSON.stringify(processedArray) !== JSON.stringify(value)) {
260
+ obj[key] = processedArray
261
+ wasModified = true
262
+ }
263
+ }
264
+ // Handle nested objects
265
+ else if (typeof value === 'object' && value !== null) {
266
+ obj[key] = await processObject(value)
267
+ }
268
+ // Handle string values
269
+ else if (typeof value === 'string') {
270
+ const processedValue = await processStringValue(value)
271
+ if (processedValue !== value) {
272
+ obj[key] = processedValue
273
+ wasModified = true
274
+ }
275
+ }
276
+ }
277
+ return obj
278
+ }
279
+
280
+ /**
281
+ * Process string values for possible image paths/URLs
282
+ * @param value - String value to process
283
+ * @returns Promise<string> - Processed string value
284
+ */
285
+ const processStringValue = async (value: string): Promise<string> => {
286
+ if (this.isImagePath(value)) {
287
+ const isInternal =
288
+ value.startsWith('./') ||
289
+ value.startsWith('../') ||
290
+ !value.startsWith('http')
291
+ const processedUrl = await this.processImage(value, isInternal)
292
+ if (processedUrl) {
293
+ return processedUrl
294
+ }
295
+ }
296
+ return value
297
+ }
298
+
299
+ await processObject(processedFrontmatter)
300
+ return { frontmatter: processedFrontmatter, wasModified }
301
+ }
302
+
303
+ /**
304
+ * Check if a string value might be an image path
305
+ * @param value - String to check
306
+ * @returns boolean - True if the string appears to be an image path
307
+ */
308
+ private isImagePath(value: string): boolean {
309
+ const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg)$/i
310
+ return (
311
+ imageExtensions.test(value) ||
312
+ value.includes('/upload/') || // For existing Strapi URLs
313
+ /^https?:\/\/.*\.(jpg|jpeg|png|gif|webp|svg)/i.test(value)
314
+ )
315
+ }
316
+
317
+ /**
318
+ * Prepare export data by processing both content and frontmatter
319
+ * @param app - Obsidian App instance
320
+ * @param file - Current TFile
321
+ * @param route - Route configuration
322
+ * @returns Promise<any> - Prepared export data
323
+ */
324
+ private async prepareExportData(
325
+ app: App,
326
+ file: TFile,
327
+ route: RouteConfig
328
+ ): Promise<any> {
329
+ const content = await app.vault.read(file)
330
+ const { frontmatter, body } = extractFrontMatterAndContent(content)
331
+
332
+ // Process images in frontmatter
333
+ const {
334
+ frontmatter: processedFrontmatter,
335
+ wasModified: frontmatterModified,
336
+ } = await this.processFrontmatterImages(frontmatter)
337
+
338
+ // Process images in content
339
+ const { content: processedContent, wasModified: contentModified } =
340
+ await this.processContentImages(body)
341
+
342
+ // Update file if modifications were made
343
+ if (frontmatterModified || contentModified) {
344
+ await this.updateObsidianFile(processedContent, processedFrontmatter)
345
+ }
346
+
347
+ return {
348
+ data: {
349
+ ...processedFrontmatter,
350
+ [route.contentField]: processedContent,
351
+ },
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Update Obsidian file with processed content and frontmatter
357
+ * @param newContent - Processed content
358
+ * @param newFrontmatter - Processed frontmatter
359
+ * @returns Promise<void>
360
+ */
361
+ private async updateObsidianFile(
362
+ newContent: string,
363
+ newFrontmatter: any
364
+ ): Promise<void> {
365
+ try {
366
+ let updatedContent = ''
367
+
368
+ // Add updated frontmatter
369
+ if (Object.keys(newFrontmatter).length > 0) {
370
+ updatedContent = '---\n'
371
+ updatedContent += yaml.dump(newFrontmatter)
372
+ updatedContent += '---\n\n'
373
+ }
374
+
375
+ // Add updated content
376
+ updatedContent += newContent
377
+
378
+ // Update the file
379
+ await this.app.vault.modify(this.file, updatedContent)
380
+ } catch (error) {
381
+ throw new Error('Failed to update Obsidian file' + error.message)
382
+ }
383
+ }
384
+
385
+ // Update the exportContent method to use the new image processing
386
+ async exportContent(
387
+ content: AnalyzedContent,
388
+ route: RouteConfig
389
+ ): Promise<void> {
390
+ this.validateSettings()
391
+ this.validateRoute(route)
392
+
393
+ const exportData = await this.prepareExportData(this.app, this.file, route)
394
+
395
+ if (exportData.data[route.contentField]) {
396
+ const { content: processedContent } = await this.processContentImages(
397
+ exportData.data[route.contentField]
398
+ )
399
+ exportData.data[route.contentField] = processedContent
400
+ }
401
+
402
+ exportData.data = await this.convertImageUrlsToIds(exportData.data)
403
+
404
+ await this.sendToStrapi(exportData, route)
405
+ }
406
+
407
+ /**
408
+ * Convert image URLs to Strapi IDs in frontmatter
409
+ * @param data - The data object containing frontmatter
410
+ * @returns Promise<any> - Updated data with image IDs
411
+ */
412
+ private async convertImageUrlsToIds(data: any): Promise<any> {
413
+ const processValue = async (value: any): Promise<any> => {
414
+ if (typeof value === 'string' && this.isImagePath(value)) {
415
+ const imageInfo = await this.checkExistingImage(value)
416
+ return imageInfo?.data?.id || value
417
+ }
418
+
419
+ if (Array.isArray(value)) {
420
+ return Promise.all(value.map(item => processValue(item)))
421
+ }
422
+
423
+ if (typeof value === 'object' && value !== null) {
424
+ const processed: any = {}
425
+ for (const [key, val] of Object.entries(value)) {
426
+ processed[key] = await processValue(val)
427
+ }
428
+ return processed
429
+ }
430
+
431
+ return value
432
+ }
433
+
434
+ return await processValue(data)
435
+ }
436
+ }
@@ -0,0 +1,206 @@
1
+ import { App, PluginSettingTab, Notice } from 'obsidian'
2
+ import StrapiExporterPlugin from '../main'
3
+ import { Dashboard } from '../components/Dashboard'
4
+ import { Configuration } from '../components/Configuration'
5
+ import { APIKeys } from '../components/APIKeys'
6
+ import { Routes } from '../components/Routes'
7
+
8
+ interface TabDefinition {
9
+ id: string
10
+ name: string
11
+ icon?: string
12
+ description?: string
13
+ component?: any
14
+ }
15
+
16
+ export class UnifiedSettingsTab extends PluginSettingTab {
17
+ plugin: StrapiExporterPlugin
18
+ private components: {
19
+ dashboard?: Dashboard
20
+ configuration?: Configuration
21
+ apiKeys?: APIKeys
22
+ routes?: Routes
23
+ } = {}
24
+ private contentContainer: HTMLElement
25
+
26
+ private readonly tabs: TabDefinition[] = [
27
+ {
28
+ id: 'dashboard',
29
+ name: 'Dashboard',
30
+ icon: 'gauge',
31
+ description: 'Overview and status',
32
+ },
33
+ {
34
+ id: 'configuration',
35
+ name: 'Configuration',
36
+ icon: 'settings',
37
+ description: 'Configure export settings',
38
+ },
39
+ {
40
+ id: 'apiKeys',
41
+ name: 'API Keys',
42
+ icon: 'key',
43
+ description: 'Manage API credentials',
44
+ },
45
+ {
46
+ id: 'routes',
47
+ name: 'Routes',
48
+ icon: 'git-branch',
49
+ description: 'Configure export routes',
50
+ },
51
+ ]
52
+
53
+ constructor(app: App, plugin: StrapiExporterPlugin) {
54
+ super(app, plugin)
55
+ this.plugin = plugin
56
+ }
57
+
58
+ display(): void {
59
+ try {
60
+ const { containerEl } = this
61
+ containerEl.empty()
62
+
63
+ this.createHeader()
64
+ this.createTabNavigation()
65
+ this.createContentContainer()
66
+ this.updateContent()
67
+ } catch (error) {
68
+ this.showError('Failed to display settings' + error.message)
69
+ }
70
+ }
71
+
72
+ private createHeader(): void {
73
+ const headerEl = this.containerEl.createDiv('settings-header')
74
+
75
+ headerEl.createEl('h1', {
76
+ text: 'Strapi Exporter Settings',
77
+ cls: 'settings-title',
78
+ })
79
+
80
+ headerEl.createEl('p', {
81
+ text: 'Configure your Strapi export settings and manage your integrations.',
82
+ cls: 'settings-description',
83
+ })
84
+ }
85
+
86
+ private createTabNavigation(): void {
87
+ const navContainer = this.containerEl.createDiv('strapi-exporter-nav')
88
+
89
+ this.tabs.forEach(tab => {
90
+ this.createTabButton(navContainer, tab)
91
+ })
92
+ }
93
+
94
+ private createTabButton(container: HTMLElement, tab: TabDefinition): void {
95
+ const button = container.createEl('button', {
96
+ cls: 'strapi-exporter-nav-button',
97
+ })
98
+
99
+ // Icon if provided
100
+ if (tab.icon) {
101
+ button.createSpan({
102
+ cls: `nav-icon ${tab.icon}`,
103
+ attr: { 'aria-hidden': 'true' },
104
+ })
105
+ }
106
+
107
+ // Button text
108
+ button.createSpan({
109
+ text: tab.name,
110
+ cls: 'nav-text',
111
+ })
112
+
113
+ // Add tooltip with description
114
+ if (tab.description) {
115
+ button.setAttribute('aria-label', tab.description)
116
+ button.setAttribute('title', tab.description)
117
+ }
118
+
119
+ // Set active state
120
+ if (this.plugin.settings.currentTab === tab.id) {
121
+ button.addClass('is-active')
122
+ }
123
+
124
+ // Add click handler
125
+ button.addEventListener('click', () => {
126
+ this.handleTabChange(tab.id, container, button)
127
+ })
128
+ }
129
+
130
+ private createContentContainer(): void {
131
+ this.contentContainer = this.containerEl.createDiv(
132
+ 'strapi-exporter-content'
133
+ )
134
+ }
135
+
136
+ private handleTabChange(
137
+ tabId: string,
138
+ container: HTMLElement,
139
+ button: HTMLElement
140
+ ): void {
141
+ try {
142
+ this.plugin.settings.currentTab = tabId
143
+ this.updateActiveButton(container, button)
144
+ this.updateContent()
145
+ } catch (error) {
146
+ this.showError('Failed to change tab' + error.message)
147
+ }
148
+ }
149
+
150
+ private updateActiveButton(
151
+ container: HTMLElement,
152
+ activeButton: HTMLElement
153
+ ): void {
154
+ container.findAll('.strapi-exporter-nav-button').forEach(btn => {
155
+ btn.removeClass('is-active')
156
+ })
157
+ activeButton.addClass('is-active')
158
+ }
159
+
160
+ private updateContent(): void {
161
+ try {
162
+ this.contentContainer.empty()
163
+ const currentTab = this.plugin.settings.currentTab
164
+
165
+ if (!this.components[currentTab]) {
166
+ this.initializeComponent(currentTab)
167
+ }
168
+
169
+ this.components[currentTab]?.display()
170
+ } catch (error) {
171
+ this.showError('Failed to update content' + error.message)
172
+ }
173
+ }
174
+
175
+ private initializeComponent(tabId: string): void {
176
+ switch (tabId) {
177
+ case 'dashboard':
178
+ this.components.dashboard = new Dashboard(
179
+ this.plugin,
180
+ this.contentContainer
181
+ )
182
+ break
183
+ case 'configuration':
184
+ this.components.configuration = new Configuration(
185
+ this.plugin,
186
+ this.contentContainer
187
+ )
188
+ break
189
+ case 'apiKeys':
190
+ this.components.apiKeys = new APIKeys(
191
+ this.plugin,
192
+ this.contentContainer
193
+ )
194
+ break
195
+ case 'routes':
196
+ this.components.routes = new Routes(this.plugin, this.contentContainer)
197
+ break
198
+ default:
199
+ throw new Error(`Unknown tab: ${tabId}`)
200
+ }
201
+ }
202
+
203
+ private showError(message: string): void {
204
+ new Notice(`Settings Error: ${message}`)
205
+ }
206
+ }
@@ -1,20 +1,31 @@
1
- /**
2
- * Image types
3
- */
4
- export interface ImageBlob {
5
- path: string
6
- blob: Blob
7
- name: string
1
+ export interface ImageDescription {
2
+ url?: string
3
+ path?: string
4
+ name?: string
8
5
  id?: any
6
+ blob?: Blob
7
+ description?: ImageMetadata
8
+ }
9
+
10
+ export interface ImageMetadata {
11
+ name: string
12
+ alternativeText: string
13
+ caption: string
14
+ width?: number
15
+ height?: number
16
+ format?: string
17
+ size?: number
18
+ }
19
+
20
+ export interface ImageProcessingResult {
21
+ content: string
22
+ processedImages: ImageDescription[]
23
+ stats?: ImageProcessingStats
9
24
  }
10
25
 
11
- /**
12
- * Image description
13
- */
14
- export interface ImageDescription extends ImageBlob {
15
- description: {
16
- name: string
17
- alternativeText: string
18
- caption: string
19
- }
26
+ export interface ImageProcessingStats {
27
+ total: number
28
+ processed: number
29
+ failed: number
30
+ skipped: number
20
31
  }
@@ -0,0 +1,3 @@
1
+ export * from './settings'
2
+ export * from './image'
3
+ export * from './route'