From b1fc02338051f01c85695b02816f2e710e41b249 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 01:26:42 +0000 Subject: [PATCH 1/8] Initial plan From ad57d59f923b7d468356735cf44863b018a55739 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 01:29:21 +0000 Subject: [PATCH 2/8] Initial analysis and plan for data mirroring feature Co-authored-by: busse <18599+busse@users.noreply.github.com> --- package-lock.json | 16 +++++++++++++--- playwright-report/index.html | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6301fe..519c8c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -523,6 +523,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -544,6 +545,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -560,6 +562,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -574,6 +577,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1069,7 +1073,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1887,7 +1890,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -2069,7 +2073,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -2435,6 +2438,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -2455,6 +2459,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4139,6 +4144,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -4156,6 +4162,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -4766,6 +4773,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -4829,6 +4837,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -4843,6 +4852,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, diff --git a/playwright-report/index.html b/playwright-report/index.html index aca9b6b..b5c5e68 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@
- \ No newline at end of file + \ No newline at end of file From 6479f8fe7edf8effd1702ce53bf2f0851254094f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 01:41:39 +0000 Subject: [PATCH 3/8] feat: Add data mirror feature to sync data to a second directory - Add feature flag 'dataMirror' to enable/disable the feature - Add IPC handlers for mirror directory selection and sync - Add preload.js API methods for mirror feature - Implement automatic sync on data writes - Add UI in Settings modal for configuring mirror directory - Add tests for the data mirror feature - Update README with documentation Co-authored-by: busse <18599+busse@users.noreply.github.com> --- README.md | 20 +++ index.html | 92 +++++++++++ main.js | 156 +++++++++++++++++- playwright-report/index.html | 2 +- preload.js | 6 + tests/data-mirror.test.js | 250 +++++++++++++++++++++++++++++ tests/helpers/mock-electron-api.js | 9 +- tests/keyboard-shortcuts.test.js | 8 +- 8 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 tests/data-mirror.test.js diff --git a/README.md b/README.md index 1281e78..4055be1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ A simple desktop RSS reader application for Mac built with Electron. The applica - **Automatic updates** - Background update checking and installation via electron-updater - **Data migration** - Automatic data structure migrations when updating to new versions - **Data persistence** - All feeds, articles, settings, and read states are stored locally +- **Data Mirror** - Mirror all data to a second directory for integration with personal knowledge management tools like Obsidian (requires feature flag) ### Coming Soon - OPML import/export support @@ -174,9 +175,28 @@ To configure your API key: The Settings area includes a feature flag system that allows you to enable or disable specific features: - **AI Article Summary**: Enable AI-powered article summaries +- **Data Mirror**: Enable mirroring of all data to a second directory Feature flags can be toggled on or off using the toggle switches in the Settings modal. Changes are saved immediately when you click "Save". +#### Data Mirror + +When the Data Mirror feature flag is enabled, a new section appears in Settings that allows you to: + +1. **Select a Mirror Directory**: Click "Browse..." to choose a directory where all RSS data will be mirrored +2. **Clear Directory**: Click the "✕" button to clear the selected directory +3. **Sync Now**: Manually trigger a sync of all data to the mirror directory + +The data is automatically synced to the mirror directory whenever any data changes (feeds, articles, read states, settings, etc.). This is useful for integration with personal knowledge management tools like Obsidian, Notion, or any other tool that can read JSON files. + +**Mirrored Files:** +- `feeds.json` - Feed subscriptions +- `categories.json` - Category/folder organization +- `read-states.json` - Article read status +- `settings.json` - Application settings +- `ai-summaries.json` - AI-generated summaries (if enabled) +- `articles-{feedId}.json` - Articles for each feed + ### Feed Management The Feed Management page allows you to: diff --git a/index.html b/index.html index 25a6ff6..f9689f0 100644 --- a/index.html +++ b/index.html @@ -1041,6 +1041,21 @@ +

Select a directory to mirror all RSS data. Useful for integration with personal knowledge management tools like Obsidian.

+
+
+
+
Sync as Markdown (.md)
+
Convert data to Markdown files with Obsidian-compatible frontmatter properties instead of JSON
+
+ +
+
@@ -1991,6 +2003,15 @@

${escapeHtml(article.title || 'Untitled')}

${escapeHtml(article.title || 'Untitled')} { } }); +ipcMain.handle('get-sync-as-markdown', async (event) => { + try { + const filePath = path.join(dataDir, 'settings.json'); + let settings = {}; + + try { + const data = await fs.readFile(filePath, 'utf-8'); + settings = JSON.parse(data); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + // Default to true (enabled) when dataMirror is enabled + const syncAsMarkdown = settings.syncAsMarkdown !== undefined ? settings.syncAsMarkdown : true; + return { success: true, data: syncAsMarkdown }; + } catch (error) { + console.error('Error getting syncAsMarkdown setting:', error); + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('set-sync-as-markdown', async (event, enabled) => { + try { + const filePath = path.join(dataDir, 'settings.json'); + let settings = {}; + + try { + const data = await fs.readFile(filePath, 'utf-8'); + settings = JSON.parse(data); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + settings.syncAsMarkdown = enabled; + + await fs.writeFile(filePath, JSON.stringify(settings, null, 2), 'utf-8'); + // Trigger sync after changing the setting + if (settings.featureFlags?.dataMirror && settings.mirrorDirectory) { + triggerMirrorSync(); + } + return { success: true }; + } catch (error) { + console.error('Error setting syncAsMarkdown:', error); + return { success: false, error: error.message }; + } +}); + ipcMain.handle('set-mirror-directory', async (event, mirrorDirectory) => { try { const filePath = path.join(dataDir, 'settings.json'); @@ -457,6 +508,143 @@ function triggerMirrorSync() { syncToMirror().catch(err => console.error('Mirror sync error:', err)); } +// Helper function to strip HTML tags and decode entities safely +function stripHtml(html) { + if (!html) return ''; + // Repeatedly remove HTML tags until none remain (handles nested/malformed tags) + let text = html; + let previous = ''; + while (previous !== text) { + previous = text; + text = text.replace(/<[^>]*>/g, ''); + } + // Decode common HTML entities (in correct order - & last to avoid double-decoding) + text = text.replace(/</g, '<'); + text = text.replace(/>/g, '>'); + text = text.replace(/"/g, '"'); + text = text.replace(/'/g, "'"); + text = text.replace(/ /g, ' '); + text = text.replace(/&/g, '&'); + // Normalize whitespace + text = text.replace(/\s+/g, ' ').trim(); + return text; +} + +// Helper function to escape a string for YAML double-quoted strings +function escapeYamlString(str) { + if (!str) return ''; + // Escape backslashes first, then double quotes, then newlines + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +// Helper function to convert article to Markdown with Obsidian frontmatter +function articleToMarkdown(article, feedTitle, readState) { + const frontmatter = { + title: article.title || 'Untitled', + link: article.link || '', + author: article.author || '', + feed: feedTitle || '', + pubDate: article.pubDate || '', + read: readState?.read || false, + readAt: readState?.readAt || '', + tags: article.categories || [], + type: 'article' + }; + + // Build YAML frontmatter with proper escaping + let yaml = '---\n'; + yaml += `title: "${escapeYamlString(frontmatter.title)}"\n`; + yaml += `link: "${escapeYamlString(frontmatter.link)}"\n`; + yaml += `author: "${escapeYamlString(frontmatter.author)}"\n`; + yaml += `feed: "${escapeYamlString(frontmatter.feed)}"\n`; + yaml += `pubDate: "${escapeYamlString(frontmatter.pubDate)}"\n`; + yaml += `read: ${frontmatter.read}\n`; + if (frontmatter.readAt) { + yaml += `readAt: "${escapeYamlString(frontmatter.readAt)}"\n`; + } + if (frontmatter.tags && frontmatter.tags.length > 0) { + yaml += `tags:\n`; + frontmatter.tags.forEach(tag => { + yaml += ` - "${escapeYamlString(tag || '')}"\n`; + }); + } + yaml += `type: article\n`; + yaml += '---\n\n'; + + // Add article title as heading + let content = `# ${article.title || 'Untitled'}\n\n`; + + // Add link to original + if (article.link) { + content += `[View Original Article](${article.link})\n\n`; + } + + // Add description/summary + if (article.description) { + content += `## Summary\n\n${stripHtml(article.description)}\n\n`; + } + + // Add full content (converted from HTML to plain text) + if (article.content) { + content += `## Content\n\n${stripHtml(article.content)}\n`; + } + + return yaml + content; +} + +// Helper function to convert feed to Markdown with Obsidian frontmatter +function feedToMarkdown(feed) { + let yaml = '---\n'; + yaml += `title: "${escapeYamlString(feed.title || 'Untitled Feed')}"\n`; + yaml += `url: "${escapeYamlString(feed.url || '')}"\n`; + yaml += `category: "${escapeYamlString(feed.category || '')}"\n`; + yaml += `icon: "${escapeYamlString(feed.icon || '')}"\n`; + yaml += `lastUpdated: "${escapeYamlString(feed.lastUpdated || '')}"\n`; + yaml += `status: "${escapeYamlString(feed.status || '')}"\n`; + yaml += `type: feed\n`; + yaml += '---\n\n'; + + let content = `# ${feed.title || 'Untitled Feed'}\n\n`; + content += `**URL:** ${feed.url || 'N/A'}\n\n`; + content += `**Status:** ${feed.status || 'unknown'}\n\n`; + if (feed.lastUpdated) { + content += `**Last Updated:** ${feed.lastUpdated}\n\n`; + } + + return yaml + content; +} + +// Helper function to convert category to Markdown with Obsidian frontmatter +function categoryToMarkdown(category) { + let yaml = '---\n'; + yaml += `name: "${escapeYamlString(category.name || 'Untitled Category')}"\n`; + yaml += `icon: "${escapeYamlString(category.icon || '')}"\n`; + yaml += `type: category\n`; + yaml += '---\n\n'; + + let content = `# ${category.icon || '📁'} ${category.name || 'Untitled Category'}\n\n`; + content += `This is a category/folder for organizing RSS feeds.\n`; + + return yaml + content; +} + +// Helper function to sanitize filename for file system +function sanitizeFilename(name) { + if (!name) return 'untitled'; + // Remove or replace invalid characters + return name + .replace(/[<>:"/\\|?*]/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 100) || 'untitled'; +} + // Helper function to sync data to mirror directory async function syncToMirror() { try { @@ -480,6 +668,7 @@ async function syncToMirror() { } const mirrorDir = settings.mirrorDirectory; + const syncAsMarkdown = settings.syncAsMarkdown !== false; // Default to true // Ensure mirror directory exists await fs.mkdir(mirrorDir, { recursive: true }); @@ -487,24 +676,108 @@ async function syncToMirror() { // Get all files in the data directory const files = await fs.readdir(dataDir); - // Copy JSON files to mirror directory in parallel - const copyPromises = files - .filter(file => file.endsWith('.json')) - .map(async (file) => { - const sourcePath = path.join(dataDir, file); - const destPath = path.join(mirrorDir, file); - - try { - const content = await fs.readFile(sourcePath, 'utf-8'); - await fs.writeFile(destPath, content, 'utf-8'); - } catch (error) { - console.error(`Error copying ${file} to mirror:`, error); + if (syncAsMarkdown) { + // Create subdirectories for organized Markdown files + const articlesDir = path.join(mirrorDir, 'articles'); + const feedsDir = path.join(mirrorDir, 'feeds'); + const categoriesDir = path.join(mirrorDir, 'categories'); + + await Promise.all([ + fs.mkdir(articlesDir, { recursive: true }), + fs.mkdir(feedsDir, { recursive: true }), + fs.mkdir(categoriesDir, { recursive: true }) + ]); + + // Load read states for article metadata + let readStates = {}; + try { + const readStatesData = await fs.readFile(path.join(dataDir, 'read-states.json'), 'utf-8'); + readStates = JSON.parse(readStatesData); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error('Error reading read states:', error); } - }); - - await Promise.all(copyPromises); - - console.log('Data synced to mirror directory:', mirrorDir); + } + + // Load feeds for reference + let feeds = []; + try { + const feedsData = await fs.readFile(path.join(dataDir, 'feeds.json'), 'utf-8'); + feeds = JSON.parse(feedsData); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error('Error reading feeds:', error); + } + } + + // Process files and convert to Markdown + const syncPromises = files + .filter(file => file.endsWith('.json')) + .map(async (file) => { + const sourcePath = path.join(dataDir, file); + + try { + const content = await fs.readFile(sourcePath, 'utf-8'); + const data = JSON.parse(content); + + if (file === 'feeds.json' && Array.isArray(data)) { + // Convert each feed to a Markdown file + const feedPromises = data.map(async (feed) => { + const mdContent = feedToMarkdown(feed); + const filename = sanitizeFilename(feed.title || feed.id) + '.md'; + await fs.writeFile(path.join(feedsDir, filename), mdContent, 'utf-8'); + }); + await Promise.all(feedPromises); + } else if (file === 'categories.json' && Array.isArray(data)) { + // Convert each category to a Markdown file + const catPromises = data.map(async (category) => { + const mdContent = categoryToMarkdown(category); + const filename = sanitizeFilename(category.name || category.id) + '.md'; + await fs.writeFile(path.join(categoriesDir, filename), mdContent, 'utf-8'); + }); + await Promise.all(catPromises); + } else if (file.startsWith('articles-') && data.articles) { + // Convert each article to a Markdown file + const feed = feeds.find(f => f.id === data.feedId); + const feedTitle = feed?.title || 'Unknown Feed'; + const feedFolder = path.join(articlesDir, sanitizeFilename(feedTitle)); + await fs.mkdir(feedFolder, { recursive: true }); + + const articlePromises = data.articles.map(async (article) => { + const readState = readStates[article.id]; + const mdContent = articleToMarkdown(article, feedTitle, readState); + const filename = sanitizeFilename(article.title || article.id) + '.md'; + await fs.writeFile(path.join(feedFolder, filename), mdContent, 'utf-8'); + }); + await Promise.all(articlePromises); + } + // Skip other files like settings.json, read-states.json, ai-summaries.json in Markdown mode + } catch (error) { + console.error(`Error converting ${file} to Markdown:`, error); + } + }); + + await Promise.all(syncPromises); + console.log('Data synced to mirror directory as Markdown:', mirrorDir); + } else { + // Copy JSON files to mirror directory in parallel (original behavior) + const copyPromises = files + .filter(file => file.endsWith('.json')) + .map(async (file) => { + const sourcePath = path.join(dataDir, file); + const destPath = path.join(mirrorDir, file); + + try { + const content = await fs.readFile(sourcePath, 'utf-8'); + await fs.writeFile(destPath, content, 'utf-8'); + } catch (error) { + console.error(`Error copying ${file} to mirror:`, error); + } + }); + + await Promise.all(copyPromises); + console.log('Data synced to mirror directory as JSON:', mirrorDir); + } } catch (error) { console.error('Error syncing to mirror:', error); } diff --git a/playwright-report/index.html b/playwright-report/index.html index 4f3ae2d..d552533 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/preload.js b/preload.js index 24083e6..bd3f4b2 100644 --- a/preload.js +++ b/preload.js @@ -43,6 +43,8 @@ contextBridge.exposeInMainWorld('electronAPI', { selectMirrorDirectory: () => ipcRenderer.invoke('select-mirror-directory'), getMirrorDirectory: () => ipcRenderer.invoke('get-mirror-directory'), setMirrorDirectory: (mirrorDirectory) => ipcRenderer.invoke('set-mirror-directory', mirrorDirectory), + getSyncAsMarkdown: () => ipcRenderer.invoke('get-sync-as-markdown'), + setSyncAsMarkdown: (enabled) => ipcRenderer.invoke('set-sync-as-markdown', enabled), syncToMirror: () => ipcRenderer.invoke('sync-to-mirror'), // AI Summary operations diff --git a/tests/data-mirror.test.js b/tests/data-mirror.test.js index 68c9864..3da9346 100644 --- a/tests/data-mirror.test.js +++ b/tests/data-mirror.test.js @@ -74,6 +74,8 @@ test.describe('Data Mirror Feature', () => { selectMirrorDirectory: async () => ({ success: true, data: '/test/mirror/directory' }), getMirrorDirectory: async () => ({ success: true, data: '/existing/mirror/path' }), setMirrorDirectory: async (mirrorDirectory) => ({ success: true }), + getSyncAsMarkdown: async () => ({ success: true, data: true }), + setSyncAsMarkdown: async (enabled) => ({ success: true }), syncToMirror: async () => ({ success: true }), // AI Summary operations @@ -219,6 +221,47 @@ test.describe('Data Mirror Feature', () => { expect(toast).toBeTruthy(); }); + test('Sync as Markdown toggle is visible and checked by default', async () => { + // Open settings modal + await page.click('#settingsBtn'); + await page.waitForTimeout(200); + + // Check that the Sync as Markdown toggle exists and is checked by default + const syncAsMarkdownToggle = await page.$('#syncAsMarkdownToggle'); + expect(syncAsMarkdownToggle).toBeTruthy(); + + const isChecked = await syncAsMarkdownToggle.isChecked(); + expect(isChecked).toBe(true); + }); + + test('Sync as Markdown toggle can be toggled', async () => { + // Open settings modal + await page.click('#settingsBtn'); + await page.waitForTimeout(200); + + // Get the toggle and verify it's checked + const syncAsMarkdownToggle = await page.$('#syncAsMarkdownToggle'); + let isChecked = await syncAsMarkdownToggle.isChecked(); + expect(isChecked).toBe(true); + + // Toggle it off by clicking the parent label + const toggleSwitch = await syncAsMarkdownToggle.evaluateHandle(el => el.parentElement); + await toggleSwitch.click(); + await page.waitForTimeout(100); + + // Verify it's now unchecked + isChecked = await syncAsMarkdownToggle.isChecked(); + expect(isChecked).toBe(false); + + // Toggle it back on + await toggleSwitch.click(); + await page.waitForTimeout(100); + + // Verify it's checked again + isChecked = await syncAsMarkdownToggle.isChecked(); + expect(isChecked).toBe(true); + }); + test('Toggling Data Mirror feature flag shows/hides section', async () => { // Open settings modal await page.click('#settingsBtn'); diff --git a/tests/helpers/mock-electron-api.js b/tests/helpers/mock-electron-api.js index 91a3c0b..4a0015f 100644 --- a/tests/helpers/mock-electron-api.js +++ b/tests/helpers/mock-electron-api.js @@ -146,6 +146,8 @@ function createMockElectronAPI() { selectMirrorDirectory: async () => ({ success: true, data: null }), getMirrorDirectory: async () => ({ success: true, data: null }), setMirrorDirectory: async (mirrorDirectory) => ({ success: true }), + getSyncAsMarkdown: async () => ({ success: true, data: true }), + setSyncAsMarkdown: async (enabled) => ({ success: true }), syncToMirror: async () => ({ success: true }), // AI Summary operations diff --git a/tests/keyboard-shortcuts.test.js b/tests/keyboard-shortcuts.test.js index 7ba0fd1..4307335 100644 --- a/tests/keyboard-shortcuts.test.js +++ b/tests/keyboard-shortcuts.test.js @@ -68,6 +68,8 @@ test.describe('Keyboard Shortcuts', () => { selectMirrorDirectory: async () => ({ success: true, data: null }), getMirrorDirectory: async () => ({ success: true, data: null }), setMirrorDirectory: async (mirrorDirectory) => ({ success: true }), + getSyncAsMarkdown: async () => ({ success: true, data: true }), + setSyncAsMarkdown: async (enabled) => ({ success: true }), syncToMirror: async () => ({ success: true }), // AI Summary operations From 0131e8b8284db3911b6ef059e046be18eca21901 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 03:56:48 +0000 Subject: [PATCH 7/8] fix: Preserve line breaks in Markdown content when stripping HTML - Convert block-level elements (p, div, h1-h6, li, tr, blockquote) to line breaks - Convert br tags to single newlines - Convert hr tags to markdown horizontal rules - Normalize multiple consecutive newlines to paragraph breaks - Trim each line while preserving paragraph structure Co-authored-by: busse <18599+busse@users.noreply.github.com> --- main.js | 34 ++++++++++++++++++++++++++++++---- playwright-report/index.html | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/main.js b/main.js index 3230cb0..cf5449c 100644 --- a/main.js +++ b/main.js @@ -508,16 +508,33 @@ function triggerMirrorSync() { syncToMirror().catch(err => console.error('Mirror sync error:', err)); } -// Helper function to strip HTML tags and decode entities safely +// Helper function to strip HTML tags and decode entities safely while preserving line breaks function stripHtml(html) { if (!html) return ''; - // Repeatedly remove HTML tags until none remain (handles nested/malformed tags) let text = html; + + // Convert block-level elements to line breaks before removing tags + // Replace closing block tags with double newlines for paragraph breaks + text = text.replace(/<\/p>/gi, '\n\n'); + text = text.replace(/<\/div>/gi, '\n\n'); + text = text.replace(/<\/h[1-6]>/gi, '\n\n'); + text = text.replace(/<\/li>/gi, '\n'); + text = text.replace(/<\/tr>/gi, '\n'); + text = text.replace(/<\/blockquote>/gi, '\n\n'); + + // Replace
tags with single newlines + text = text.replace(//gi, '\n'); + + // Replace
with a line + text = text.replace(//gi, '\n---\n'); + + // Repeatedly remove remaining HTML tags until none remain (handles nested/malformed tags) let previous = ''; while (previous !== text) { previous = text; text = text.replace(/<[^>]*>/g, ''); } + // Decode common HTML entities (in correct order - & last to avoid double-decoding) text = text.replace(/</g, '<'); text = text.replace(/>/g, '>'); @@ -525,8 +542,17 @@ function stripHtml(html) { text = text.replace(/'/g, "'"); text = text.replace(/ /g, ' '); text = text.replace(/&/g, '&'); - // Normalize whitespace - text = text.replace(/\s+/g, ' ').trim(); + + // Normalize multiple consecutive newlines to max 2 (paragraph break) + text = text.replace(/\n{3,}/g, '\n\n'); + + // Normalize spaces within lines (but preserve newlines) + text = text.replace(/[^\S\n]+/g, ' '); + + // Trim each line and remove empty lines at start/end + text = text.split('\n').map(line => line.trim()).join('\n'); + text = text.replace(/^\n+|\n+$/g, ''); + return text; } diff --git a/playwright-report/index.html b/playwright-report/index.html index d552533..539ed33 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@
- \ No newline at end of file + \ No newline at end of file From 830ce00750ffc8393e4a22f04e4caa5e4f2ee690 Mon Sep 17 00:00:00 2001 From: busse Date: Wed, 3 Dec 2025 23:12:03 -0500 Subject: [PATCH 8/8] updated README for mirror data to second directory --- README.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0b8966c..82c314c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A simple desktop RSS reader application for Mac built with Electron. The applica - **OpenAI API key management** - Secure, encrypted storage of API keys using AES-256-GCM - **Feature flags** - Toggle individual features on/off to customize your experience - **Settings modal** - Centralized configuration interface accessible from the sidebar +- **Data Mirror with Markdown sync** - Mirror all RSS data to an external directory, with option to convert to Markdown files with Obsidian-compatible frontmatter ![Settings](screenshots/settings.png) @@ -184,19 +185,27 @@ Feature flags can be toggled on or off using the toggle switches in the Settings When the Data Mirror feature flag is enabled, a new section appears in Settings that allows you to: 1. **Select a Mirror Directory**: Click "Browse..." to choose a directory where all RSS data will be mirrored -2. **Sync as Markdown**: Toggle to convert data to Markdown files with Obsidian-compatible frontmatter (enabled by default) +2. **Sync as Markdown** (enabled by default): Toggle to convert data to Markdown files with Obsidian-compatible frontmatter instead of raw JSON files 3. **Clear Directory**: Click the "✕" button to clear the selected directory 4. **Sync Now**: Manually trigger a sync of all data to the mirror directory The data is automatically synced to the mirror directory whenever any data changes (feeds, articles, read states, settings, etc.). This is useful for integration with personal knowledge management tools like Obsidian, Notion, or any other tool that can read Markdown or JSON files. +**Note:** The "Sync as Markdown" option is enabled by default, providing a more readable and organized format for your mirrored data. You can disable it to mirror raw JSON files instead. + **Sync as Markdown Mode (Default):** -When "Sync as Markdown" is enabled, the mirrored data is organized into folders and converted to Markdown files with [Obsidian-compatible frontmatter properties](https://help.obsidian.md/properties): +When "Sync as Markdown" is enabled (the default), the mirrored data is organized into a clean folder structure and converted to Markdown files with [Obsidian-compatible frontmatter properties](https://help.obsidian.md/properties): + +- `articles/{feed-name}/` - Individual article files, one per article, with full content and metadata +- `feeds/` - Feed subscription files with URL, status, category, and update information +- `categories/` - Category/folder definition files -- `articles/{feed-name}/` - Individual article files with title, link, author, pubDate, read status, and tags in frontmatter -- `feeds/` - Feed subscription files with URL, status, and category info -- `categories/` - Category/folder files +This format makes it easy to: +- Browse and search articles in your knowledge management tool +- Link between articles, feeds, and categories +- Use Obsidian's graph view to visualize your reading patterns +- Export or backup your RSS data in a human-readable format Each Markdown file includes YAML frontmatter with properties like: ```yaml