notes-to-strapi-export-article-ai 3.0.3 → 3.0.5

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/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![Sponsor](https://img.shields.io/badge/sponsor-CinquinAndy-purple)](https://github.com/sponsors/CinquinAndy)
6
6
 
7
7
  Strapi Exporter is a game-changing Obsidian plugin that streamlines your content creation process by seamlessly exporting your notes to Strapi CMS. With its AI-powered image handling and SEO optimization features, you can take your content to the next level with just a few clicks.
8
+ ! This plugin was totally refactored, since the 3.0.0 version ! Restart your configuration !
8
9
 
9
10
  ## ✨ Key Features
10
11
 
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "notes-to-strapi-export-article-ai",
3
3
  "name": "Strapi Exporter AI",
4
- "version": "3.0.3",
4
+ "version": "3.0.5",
5
5
  "minAppVersion": "1.7.0",
6
6
  "description": "Effortlessly export your notes to Strapi CMS with AI-powered handling and SEO optimization.",
7
7
  "author": "Cinquin Andy",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notes-to-strapi-export-article-ai",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "Effortlessly export your Obsidian notes to Strapi CMS with AI-powered image handling and SEO optimization. Replace all the images in your notes by uploaded images in Strapi, and add SEO metadata to uploaded images.",
5
5
  "main": "main.js",
6
6
  "scripts": {
@@ -1,8 +1,11 @@
1
1
  import { generateText } from 'ai'
2
2
  import { createOpenAI } from '@ai-sdk/openai'
3
- import { App, TFile } from 'obsidian'
3
+ import { App, Notice, TFile } from 'obsidian'
4
4
  import { RouteConfig } from '../types'
5
5
  import StrapiExporterPlugin from '../main'
6
+ import { processContentLinks } from '../utils/process-file'
7
+ import { extractFrontMatterAndContent } from '../utils/analyse-file'
8
+ import yaml from 'js-yaml'
6
9
 
7
10
  /**
8
11
  * Interface defining the structure of a field in the schema
@@ -55,11 +58,72 @@ export class FrontmatterGenerator {
55
58
  * @returns Promise<string> Updated content with new frontmatter
56
59
  */
57
60
  async updateContentFrontmatter(file: TFile, app: App): Promise<string> {
58
- const content = await app.vault.read(file)
59
- const newFrontmatter = await this.generateFrontmatter(file, app)
60
- const updatedContent = this.replaceFrontmatter(content, newFrontmatter)
61
+ try {
62
+ // Read original content
63
+ const originalContent = await app.vault.read(file)
64
+
65
+ // Process images in the content first
66
+ const processedContent = await this.processContentWithImages(
67
+ originalContent,
68
+ app
69
+ )
70
+
71
+ // Generate new frontmatter
72
+ const newFrontmatter = await this.generateFrontmatter(
73
+ file,
74
+ app,
75
+ processedContent
76
+ )
77
+
78
+ // Replace or add frontmatter to the processed content
79
+ const updatedContent = this.replaceFrontmatter(
80
+ processedContent,
81
+ newFrontmatter
82
+ )
83
+
84
+ return updatedContent
85
+ } catch (error) {
86
+ new Notice(`Error processing content: ${error.message}`)
87
+ throw error
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Process content including images
93
+ * @param content - Original content
94
+ * @param app - The Obsidian app instance
95
+ * @returns Promise<string> Processed content with updated image links
96
+ */
97
+ private async processContentWithImages(
98
+ content: string,
99
+ app: App
100
+ ): Promise<string> {
101
+ try {
102
+ const { frontmatter, body } = extractFrontMatterAndContent(content)
103
+
104
+ // Process only the images in the content, leaving regular links unchanged
105
+ const processedContent = await processContentLinks(
106
+ body,
107
+ app,
108
+ this.plugin.settings
109
+ )
110
+
111
+ // If frontmatter exists, reconstruct the content with it
112
+ if (Object.keys(frontmatter).length > 0) {
113
+ return [
114
+ '---',
115
+ yaml.dump(frontmatter),
116
+ '---',
117
+ '',
118
+ processedContent,
119
+ ].join('\n')
120
+ }
61
121
 
62
- return updatedContent
122
+ return processedContent
123
+ } catch (error) {
124
+ new Notice(`Error processing images: ${error.message}`)
125
+ throw error
126
+ }
63
127
  }
64
128
 
65
129
  /**
@@ -105,10 +169,8 @@ export class FrontmatterGenerator {
105
169
  return ['example1', 'example2']
106
170
  }
107
171
 
108
- // Analyze transform string to determine array structure
109
172
  const transform = field.transform
110
173
 
111
- // Handle different array structures based on transform content
112
174
  if (transform.includes('name:')) {
113
175
  return [
114
176
  { name: 'example1', id: 1 },
@@ -144,7 +206,7 @@ export class FrontmatterGenerator {
144
206
  .map(([field]) => field)
145
207
  .join(', ')
146
208
 
147
- const prompt = `Generate YAML frontmatter that follows this exact schema and format:
209
+ return `Generate YAML frontmatter that follows this exact schema and format:
148
210
 
149
211
  Schema Definition:
150
212
  ${JSON.stringify(config.fieldMappings, null, 2)}
@@ -169,8 +231,6 @@ DO NOT include the content IN the generated frontmatter. Just use the content to
169
231
  the content field is ${config.contentField}. Please delete the content field from the frontmatter.
170
232
 
171
233
  Return complete YAML frontmatter with opening and closing "---" markers.`
172
-
173
- return prompt
174
234
  }
175
235
 
176
236
  /**
@@ -233,13 +293,18 @@ Return complete YAML frontmatter with opening and closing "---" markers.`
233
293
  }
234
294
 
235
295
  /**
236
- * Main method to generate frontmatter based on file content and schema
296
+ * Generates frontmatter based on file content and schema
237
297
  * @param file - The file to process
238
298
  * @param app - The Obsidian app instance
299
+ * @param processedContent - Optional pre-processed content
239
300
  * @returns Promise<string> Generated frontmatter
240
301
  */
241
- async generateFrontmatter(file: TFile, app: App): Promise<string> {
242
- const content = await app.vault.read(file)
302
+ async generateFrontmatter(
303
+ file: TFile,
304
+ app: App,
305
+ processedContent?: string
306
+ ): Promise<string> {
307
+ const content = processedContent || (await app.vault.read(file))
243
308
  const currentRoute = this.getCurrentRoute()
244
309
 
245
310
  if (!currentRoute?.generatedConfig) {
@@ -1,16 +1,25 @@
1
1
  import { RouteConfig, AnalyzedContent } from '../types'
2
2
  import { StrapiExporterSettings } from '../types/settings'
3
3
  import { extractFrontMatterAndContent } from '../utils/analyse-file'
4
- import { App, TFile } from 'obsidian'
4
+ import { App, Notice, TFile } from 'obsidian'
5
5
  import { uploadImageToStrapi } from '../utils/strapi-uploader'
6
6
  import * as yaml from 'js-yaml'
7
+ import { createOpenAI } from '@ai-sdk/openai'
8
+ import { generateText } from 'ai'
7
9
 
8
10
  export class StrapiExportService {
11
+ private model
12
+
9
13
  constructor(
10
14
  private settings: StrapiExporterSettings,
11
15
  private app: App,
12
16
  private file: TFile
13
- ) {}
17
+ ) {
18
+ const openai = createOpenAI({
19
+ apiKey: this.settings.openaiApiKey,
20
+ })
21
+ this.model = openai('gpt-4o-mini')
22
+ }
14
23
 
15
24
  private async sendToStrapi(data: any, route: RouteConfig): Promise<void> {
16
25
  const url = `${this.settings.strapiUrl}${route.url}`
@@ -390,18 +399,247 @@ export class StrapiExportService {
390
399
  this.validateSettings()
391
400
  this.validateRoute(route)
392
401
 
393
- const exportData = await this.prepareExportData(this.app, this.file, route)
402
+ try {
403
+ let exportData = await this.prepareExportData(this.app, this.file, route)
394
404
 
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
405
+ if (exportData.data[route.contentField]) {
406
+ const { content: processedContent } = await this.processContentImages(
407
+ exportData.data[route.contentField]
408
+ )
409
+ exportData.data[route.contentField] = processedContent
410
+ }
411
+
412
+ exportData.data = await this.convertImageUrlsToIds(exportData.data)
413
+
414
+ try {
415
+ await this.sendToStrapi(exportData, route)
416
+ } catch (strapiError) {
417
+ new Notice(`Failed to upload, retrying with simplified data...`)
418
+ // Parse and analyze the error
419
+ const errorDetails = await this.parseAndAnalyzeError(strapiError)
420
+
421
+ // Regenerate data with error details
422
+ exportData = await this.regenerateDataWithError(
423
+ exportData,
424
+ errorDetails,
425
+ route
426
+ )
427
+
428
+ // Retry sending to Strapi
429
+ await this.sendToStrapi(exportData, route)
430
+ }
431
+ } catch (error) {
432
+ throw new Error(`Export failed: ${error.message}`)
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Process individual error messages from Strapi response
438
+ */
439
+ private async parseAndAnalyzeError(error: any): Promise<any> {
440
+ try {
441
+ // If we already have JSON data in the error response
442
+ if (error.response?.data) {
443
+ return {
444
+ statusCode: error.response.status,
445
+ error: error.response.data.error,
446
+ details: error.response.data.error?.details || {},
447
+ message: error.response.data.error?.message || 'Unknown error',
448
+ }
449
+ }
450
+
451
+ // Handle string error message
452
+ if (typeof error === 'string') {
453
+ return {
454
+ statusCode: 500,
455
+ error: { message: error },
456
+ details: {},
457
+ message: error,
458
+ }
459
+ }
460
+
461
+ // Extract detailed error info
462
+ const errorInfo = error.toString().split(': ').pop()
463
+ let parsedError
464
+
465
+ try {
466
+ parsedError = JSON.parse(errorInfo)
467
+ } catch {
468
+ parsedError = { message: errorInfo }
469
+ }
470
+
471
+ return {
472
+ statusCode: error.response?.status || 500,
473
+ error: parsedError,
474
+ details: parsedError.details || {},
475
+ message: parsedError.message || 'Unknown error',
476
+ }
477
+ } catch (e) {
478
+ console.error('Error parsing Strapi response:', e)
479
+ return {
480
+ statusCode: error.response?.status || 500,
481
+ error: { message: 'Failed to parse error response' },
482
+ details: {},
483
+ message: error.message || 'Unknown error',
484
+ }
400
485
  }
486
+ }
401
487
 
402
- exportData.data = await this.convertImageUrlsToIds(exportData.data)
488
+ /**
489
+ * Regenerate data based on Strapi error feedback
490
+ */
491
+ private async regenerateDataWithError(
492
+ originalData: any,
493
+ errorDetails: any,
494
+ route: RouteConfig
495
+ ): Promise<any> {
496
+ const { text } = await generateText({
497
+ model: this.model,
498
+ system: `You are an API expert specializing in Strapi CMS. You MUST return only valid JSON data.
499
+ Do not include any explanations or text outside of the JSON structure.
500
+ The response must be parseable by JSON.parse().`,
501
+ prompt: `
502
+ Fix this failed Strapi API request.
503
+
504
+ Error Information:
505
+ Status Code: ${errorDetails.statusCode}
506
+ Message: ${errorDetails.message}
507
+ Details: ${JSON.stringify(errorDetails.details, null, 2)}
508
+
509
+ Original Request Data:
510
+ ${JSON.stringify(originalData, null, 2)}
511
+
512
+ Route Configuration and Schema:
513
+ ${route.generatedConfig}
514
+
515
+ IMPORTANT: Return ONLY valid JSON in the exact format:
516
+ {
517
+ "data": {
518
+ // corrected fields here
519
+ }
520
+ }
521
+
522
+ If the error is something similar with an Unknown error, try to delete the complexe fields, galleries, lists, etc. and keep only the simple fields.
523
+
524
+ Do not include any text explanations - use the "__comment" field inside the JSON if needed.
525
+ The entire response must be valid JSON that can be parsed with JSON.parse().
526
+ `,
527
+ })
528
+
529
+ try {
530
+ // First, try to clean the response if needed
531
+ const cleanedText = this.cleanAIResponse(text)
532
+
533
+ let correctedData: any
534
+ try {
535
+ correctedData = JSON.parse(cleanedText)
536
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
537
+ } catch (e) {
538
+ // If parsing fails, attempt to extract JSON from the response
539
+ const extractedJson = this.extractJsonFromText(cleanedText)
540
+ if (!extractedJson) {
541
+ console.error('Original AI response:', text)
542
+ console.error('Cleaned response:', cleanedText)
543
+ throw new Error('Could not extract valid JSON from AI response')
544
+ }
545
+ correctedData = JSON.parse(extractedJson)
546
+ }
547
+
548
+ // Validate basic structure
549
+ if (!correctedData.data) {
550
+ correctedData = { data: correctedData }
551
+ }
552
+
553
+ // Log for debugging
554
+ console.log('Original data:', originalData)
555
+ console.log('Error details:', errorDetails)
556
+ console.log('Corrected data:', correctedData)
557
+
558
+ if (correctedData.__comment) {
559
+ console.log('Correction comment:', correctedData.__comment)
560
+ new Notice(`Changes made: ${correctedData.__comment}`)
561
+ delete correctedData.__comment
562
+ }
563
+
564
+ return correctedData
565
+ } catch (e) {
566
+ console.error('AI Response:', text)
567
+ console.error('Generation error:', e)
568
+
569
+ // Fallback: return a sanitized version of the original data
570
+ return this.createFallbackData(originalData, errorDetails)
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Clean AI response text to ensure valid JSON
576
+ */
577
+ private cleanAIResponse(text: string): string {
578
+ // Remove markdown code blocks if present
579
+ // eslint-disable-next-line no-useless-escape
580
+ text = text.replace(/```json\n?|\```\n?/g, '')
581
+
582
+ // Remove any leading/trailing whitespace
583
+ text = text.trim()
584
+
585
+ // Remove any text before the first {
586
+ const firstBrace = text.indexOf('{')
587
+ if (firstBrace > 0) {
588
+ text = text.substring(firstBrace)
589
+ }
590
+
591
+ // Remove any text after the last }
592
+ const lastBrace = text.lastIndexOf('}')
593
+ if (lastBrace !== -1 && lastBrace < text.length - 1) {
594
+ text = text.substring(0, lastBrace + 1)
595
+ }
596
+
597
+ return text
598
+ }
599
+
600
+ /**
601
+ * Extract JSON from text that might contain other content
602
+ */
603
+ private extractJsonFromText(text: string): string | null {
604
+ const jsonRegex = /{[\s\S]*}/
605
+ const match = text.match(jsonRegex)
606
+ return match ? match[0] : null
607
+ }
608
+
609
+ /**
610
+ * Create fallback data when regeneration fails
611
+ */
612
+ private createFallbackData(originalData: any, errorDetails: any): any {
613
+ console.log('Using fallback data generation')
614
+
615
+ // Start with the original data
616
+ const fallbackData = { ...originalData }
617
+
618
+ // Remove fields mentioned in error details
619
+ if (errorDetails.details?.errors) {
620
+ errorDetails.details.errors.forEach((error: any) => {
621
+ if (error.path) {
622
+ const path = Array.isArray(error.path) ? error.path : [error.path]
623
+ let current = fallbackData.data
624
+ for (let i = 0; i < path.length - 1; i++) {
625
+ if (current[path[i]]) {
626
+ current = current[path[i]]
627
+ }
628
+ }
629
+ const lastKey = path[path.length - 1]
630
+ if (current[lastKey]) {
631
+ delete current[lastKey]
632
+ }
633
+ }
634
+ })
635
+ }
636
+
637
+ // Ensure the data property exists
638
+ if (!fallbackData.data) {
639
+ fallbackData.data = {}
640
+ }
403
641
 
404
- await this.sendToStrapi(exportData, route)
642
+ return fallbackData
405
643
  }
406
644
 
407
645
  /**
@@ -2,76 +2,127 @@ import { App, TFile } from 'obsidian'
2
2
  import { StrapiExporterSettings, AnalyzedContent } from '../types'
3
3
  import { uploadImageToStrapi } from './strapi-uploader'
4
4
 
5
+ /**
6
+ * Types for link detection and processing
7
+ */
8
+ interface LinkMatch {
9
+ type: 'link'
10
+ fullMatch: string
11
+ text: string
12
+ url: string
13
+ }
14
+
5
15
  interface ImageMatch {
16
+ type: 'image'
6
17
  fullMatch: string
7
18
  altText: string
8
- imagePath: string
19
+ path: string
20
+ isWikiLink: boolean
9
21
  }
10
22
 
11
- /**
12
- * Process images in content fields and upload them to Strapi
13
- */
14
- export async function processImages(
15
- content: AnalyzedContent,
16
- app: App,
17
- settings: StrapiExporterSettings
18
- ): Promise<AnalyzedContent> {
19
- const processedContent = { ...content }
23
+ export function extractLinks(content: string): (LinkMatch | ImageMatch)[] {
24
+ const matches: (LinkMatch | ImageMatch)[] = []
20
25
 
21
- for (const [key, value] of Object.entries(processedContent)) {
22
- if (typeof value === 'string') {
23
- processedContent[key] = await processImageLinks(value, app, settings)
24
- }
26
+ // Regular expressions for different link types
27
+ const patterns = {
28
+ wikiImage: /!\[\[([^\]]+\.(png|jpe?g|gif|svg|bmp|webp))\]\]/gi,
29
+ markdownImage: /!\[([^\]]*)\]\(([^)]+\.(png|jpe?g|gif|svg|bmp|webp))\)/gi,
30
+ standardLink: /(?<!!)\[([^\]]+)\]\(([^)]+)\)/gi, // Negative lookbehind to exclude image links
31
+ pastedImage: /\[\[Pasted image [0-9-]+\.png\]\]/gi,
25
32
  }
26
33
 
27
- return processedContent
34
+ // Process Wiki-style image links
35
+ let match
36
+ while ((match = patterns.wikiImage.exec(content)) !== null) {
37
+ matches.push({
38
+ type: 'image',
39
+ fullMatch: match[0],
40
+ altText: match[1].split('|')[1] || match[1].split('/').pop() || '',
41
+ path: match[1].split('|')[0],
42
+ isWikiLink: true,
43
+ })
44
+ }
45
+
46
+ // Process Markdown-style image links
47
+ while ((match = patterns.markdownImage.exec(content)) !== null) {
48
+ matches.push({
49
+ type: 'image',
50
+ fullMatch: match[0],
51
+ altText: match[1],
52
+ path: match[2],
53
+ isWikiLink: false,
54
+ })
55
+ }
56
+
57
+ // Process pasted images
58
+ while ((match = patterns.pastedImage.exec(content)) !== null) {
59
+ matches.push({
60
+ type: 'image',
61
+ fullMatch: match[0],
62
+ altText: 'Pasted image',
63
+ path: match[0].slice(2, -2), // Remove [[ and ]]
64
+ isWikiLink: true,
65
+ })
66
+ }
67
+
68
+ // Process standard links (non-image)
69
+ while ((match = patterns.standardLink.exec(content)) !== null) {
70
+ matches.push({
71
+ type: 'link',
72
+ fullMatch: match[0],
73
+ text: match[1],
74
+ url: match[2],
75
+ })
76
+ }
77
+
78
+ return matches
28
79
  }
29
80
 
30
81
  /**
31
- * Process image links in content string
82
+ * Process identified links and images
32
83
  */
33
- async function processImageLinks(
84
+ export async function processContentLinks(
34
85
  content: string,
35
86
  app: App,
36
87
  settings: StrapiExporterSettings
37
88
  ): Promise<string> {
38
- const imageMatches = extractImageMatches(content)
39
-
40
89
  let processedContent = content
41
-
42
- for (const match of imageMatches) {
43
- const replacedContent = await processImageMatch(
44
- match,
45
- processedContent,
46
- app,
47
- settings
48
- )
49
-
50
- if (replacedContent !== processedContent) {
51
- processedContent = replacedContent
90
+ const matches = extractLinks(content)
91
+
92
+ for (const match of matches) {
93
+ if (match.type === 'image') {
94
+ const processedImage = await processImageMatch(match, app, settings)
95
+ if (processedImage) {
96
+ processedContent = processedContent.replace(
97
+ match.fullMatch,
98
+ processedImage
99
+ )
100
+ }
52
101
  }
102
+ // Standard links are left unchanged
53
103
  }
54
104
 
55
105
  return processedContent
56
106
  }
57
107
 
58
108
  /**
59
- * Extract image matches from content
109
+ * Process images in content fields and upload them to Strapi
60
110
  */
61
- function extractImageMatches(content: string): ImageMatch[] {
62
- const matches: ImageMatch[] = []
63
- const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
64
- let match
111
+ export async function processImages(
112
+ content: AnalyzedContent,
113
+ app: App,
114
+ settings: StrapiExporterSettings
115
+ ): Promise<AnalyzedContent> {
116
+ const processedContent = { ...content }
65
117
 
66
- while ((match = imageRegex.exec(content)) !== null) {
67
- matches.push({
68
- fullMatch: match[0],
69
- altText: match[1],
70
- imagePath: match[2],
71
- })
118
+ for (const [key, value] of Object.entries(processedContent)) {
119
+ if (typeof value === 'string') {
120
+ // Process all links and images in the content
121
+ processedContent[key] = await processContentLinks(value, app, settings)
122
+ }
72
123
  }
73
124
 
74
- return matches
125
+ return processedContent
75
126
  }
76
127
 
77
128
  /**
@@ -79,44 +130,41 @@ function extractImageMatches(content: string): ImageMatch[] {
79
130
  */
80
131
  async function processImageMatch(
81
132
  match: ImageMatch,
82
- content: string,
83
133
  app: App,
84
134
  settings: StrapiExporterSettings
85
- ): Promise<string> {
86
- if (isExternalUrl(match.imagePath)) {
87
- return content
135
+ ): Promise<string | null> {
136
+ // Skip external URLs
137
+ if (isExternalUrl(match.path)) {
138
+ return null
88
139
  }
89
140
 
90
- const file = app.vault.getAbstractFileByPath(match.imagePath)
141
+ const file = app.vault.getAbstractFileByPath(match.path)
91
142
  if (!(file instanceof TFile)) {
92
- return content
143
+ return null
93
144
  }
94
145
 
95
- const uploadedImage = await uploadImageToStrapi(
96
- file,
97
- file.name,
98
- settings,
99
- app,
100
- {
101
- alternativeText: match.altText || file.basename,
102
- caption: match.altText || file.basename,
103
- }
104
- )
105
-
106
- if (uploadedImage?.url) {
107
- const newContent = content.replace(
108
- match.fullMatch,
109
- `![${match.altText}](${uploadedImage.url})`
146
+ try {
147
+ const uploadedImage = await uploadImageToStrapi(
148
+ file,
149
+ file.name,
150
+ settings,
151
+ app,
152
+ {
153
+ alternativeText: match.altText || file.basename,
154
+ caption: match.altText || file.basename,
155
+ }
110
156
  )
111
- return newContent
157
+
158
+ if (uploadedImage?.url) {
159
+ return `![${match.altText}](${uploadedImage.url})`
160
+ }
161
+ } catch (error) {
162
+ console.error(`Failed to process image ${match.path}:`, error)
112
163
  }
113
164
 
114
- return content
165
+ return null
115
166
  }
116
167
 
117
- /**
118
- * Check if URL is external
119
- */
120
168
  function isExternalUrl(url: string): boolean {
121
169
  return url.startsWith('http://') || url.startsWith('https://')
122
170
  }