import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
-import { PhCircle, PhStar, PhClock } from '@phosphor-icons/vue';
+import { PhCheckSquare, PhCircle, PhSquare, PhStar, PhClock } from '@phosphor-icons/vue';
import { useArticleDateFormat } from '@/composables/article/useArticleDateFormat';
import { useArticleHoverRead } from '@/composables/article/useArticleHoverRead';
import { useSettings } from '@/composables/core/useSettings';
@@ -13,6 +13,8 @@ const props = defineProps<{
columns: ArticleTableColumn[];
isActive: boolean;
disabled?: boolean;
+ selectionMode?: boolean;
+ selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
@@ -26,7 +28,7 @@ const { formatArticleDate, formatArticleDateTime } = useArticleDateFormat();
const hover = useArticleHoverRead(
() => props.article,
(id) => emit('hoverMarkAsRead', id),
- () => props.disabled === true
+ () => props.disabled === true || props.selectionMode === true
);
const hasTranslation = computed(
() => props.article.translated_title && props.article.translated_title !== props.article.title
@@ -40,6 +42,7 @@ const hasTranslation = computed(
:class="[
'cursor-pointer border-b border-border hover:bg-bg-secondary',
isActive ? 'bg-accent/10' : '',
+ selected ? 'bg-accent/10 outline outline-1 -outline-offset-1 outline-accent/50' : '',
article.is_read ? 'text-text-secondary' : 'text-text-primary font-medium',
]"
@click="emit('click')"
@@ -50,17 +53,23 @@ const hasTranslation = computed(
- {{ article.translated_title }}
- {{ article.title }}
+
+
+
+
+
+ {{ article.translated_title }}
+ {{ article.title }}
+
{{
article.feed_title
diff --git a/frontend/src/components/article/imageGallery/components/ImageCard.vue b/frontend/src/components/article/imageGallery/components/ImageCard.vue
index 17489328f..b15d14b44 100644
--- a/frontend/src/components/article/imageGallery/components/ImageCard.vue
+++ b/frontend/src/components/article/imageGallery/components/ImageCard.vue
@@ -2,6 +2,7 @@
import { useArticleDateFormat } from '@/composables/article/useArticleDateFormat';
import { PhImage, PhStar, PhPlay } from '@phosphor-icons/vue';
import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
import type { Article } from '@/types/models';
import { getProxiedMediaUrl } from '@/utils/mediaProxy';
import { isYouTubeArticle, extractYouTubeVideoId, getYouTubeThumbnailUrl } from '@/utils/youtube';
@@ -15,6 +16,7 @@ interface Props {
}
const props = defineProps();
+const { t } = useI18n();
const emit = defineEmits<{
imageSize: [width: number, height: number];
@@ -120,10 +122,20 @@ const { formatArticleDate: formatDate, formatArticleDateTime } = useArticleDateF
"
/>
+
+
+ {{ t('article.table.unread') }}
+
+
{{ platformBadge.label }}
diff --git a/frontend/src/components/common/ContextMenu.test.ts b/frontend/src/components/common/ContextMenu.test.ts
new file mode 100644
index 000000000..13a80c965
--- /dev/null
+++ b/frontend/src/components/common/ContextMenu.test.ts
@@ -0,0 +1,29 @@
+import { mount } from '@vue/test-utils';
+import { afterEach, describe, expect, it } from 'vitest';
+import ContextMenu from './ContextMenu.vue';
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('ContextMenu', () => {
+ it('closes when an overlay stops bubbling outside clicks', async () => {
+ const wrapper = mount(ContextMenu, {
+ attachTo: document.body,
+ props: {
+ items: [{ label: 'Action', action: 'action' }],
+ x: 10,
+ y: 10,
+ },
+ });
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+
+ const overlay = document.createElement('div');
+ overlay.addEventListener('click', (event) => event.stopPropagation());
+ document.body.appendChild(overlay);
+ overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+
+ expect(wrapper.emitted('close')).toHaveLength(1);
+ wrapper.unmount();
+ });
+});
diff --git a/frontend/src/components/common/ContextMenu.vue b/frontend/src/components/common/ContextMenu.vue
index aa877f772..8477e10b0 100644
--- a/frontend/src/components/common/ContextMenu.vue
+++ b/frontend/src/components/common/ContextMenu.vue
@@ -115,8 +115,9 @@ onMounted(() => {
// Use setTimeout to avoid catching the event that opened the menu
document.addEventListener('keydown', handleKeyDown, true);
listenerTimer = setTimeout(() => {
- document.addEventListener('click', handleClickOutside);
- document.addEventListener('contextmenu', handleClickOutside);
+ // Capture outside presses before card/detail overlays stop propagation.
+ document.addEventListener('click', handleClickOutside, true);
+ document.addEventListener('contextmenu', handleClickOutside, true);
}, 0);
});
@@ -131,8 +132,8 @@ watch(
onUnmounted(() => {
clearTimeout(listenerTimer);
document.removeEventListener('keydown', handleKeyDown, true);
- document.removeEventListener('click', handleClickOutside);
- document.removeEventListener('contextmenu', handleClickOutside);
+ document.removeEventListener('click', handleClickOutside, true);
+ document.removeEventListener('contextmenu', handleClickOutside, true);
});
function handleAction(item: ContextMenuItem) {
diff --git a/frontend/src/components/modals/SettingsModal.vue b/frontend/src/components/modals/SettingsModal.vue
index 41f10fa25..c8acf2bcb 100644
--- a/frontend/src/components/modals/SettingsModal.vue
+++ b/frontend/src/components/modals/SettingsModal.vue
@@ -36,9 +36,10 @@ import { useSettings } from '@/composables/core/useSettings';
import { useAppUpdates } from '@/composables/core/useAppUpdates';
import { useFeedManagement } from '@/composables/feed/useFeedManagement';
import { useModalClose, LARGE_MODAL_Z_INDEX } from '@/composables/ui/useModalClose';
+import { settingsSearchKeys } from '@/config/settingsSearch';
const store = useAppStore();
-const { t, tm } = useI18n();
+const { t } = useI18n();
interface Props {
initialTab?: TabName;
@@ -96,80 +97,61 @@ const settingsTabs: Array<{
id: TabName;
icon: Component;
labelKey: string;
- searchNamespaces: string[];
}> = [
{
id: 'general',
icon: PhSlidersHorizontal,
labelKey: 'setting.tab.general',
- searchNamespaces: ['setting.general', 'setting.update', 'setting.database'],
},
{
id: 'reading',
icon: PhBookOpen,
labelKey: 'setting.tab.readingAndDisplay',
- searchNamespaces: ['setting.reading', 'setting.typography', 'setting.customization'],
},
{
id: 'feeds',
icon: PhRss,
labelKey: 'sidebar.feedList.feeds',
- searchNamespaces: ['setting.feed', 'modal.feed', 'modal.discovery'],
},
{
id: 'content',
icon: PhTextT,
labelKey: 'setting.tab.content',
- searchNamespaces: ['setting.content', 'setting.translation'],
},
{
id: 'ai',
icon: PhBrain,
labelKey: 'setting.tab.ai',
- searchNamespaces: ['setting.ai', 'aiErrors'],
},
{
id: 'rules',
icon: PhFunnel,
labelKey: 'modal.rule.rules',
- searchNamespaces: ['setting.rule', 'modal.rule'],
},
{
id: 'network',
icon: PhGlobe,
labelKey: 'setting.tab.network',
- searchNamespaces: ['setting.network'],
},
{
id: 'plugins',
icon: PhPuzzlePiece,
labelKey: 'setting.tab.plugins',
- searchNamespaces: [
- 'setting.plugins',
- 'setting.freshrss',
- 'setting.rsshub',
- 'setting.notion',
- 'setting.obsidian',
- 'setting.zotero',
- ],
},
{
id: 'shortcuts',
icon: PhKeyboard,
labelKey: 'setting.shortcut.shortcuts',
- searchNamespaces: ['setting.shortcut'],
},
{
id: 'statistics',
icon: PhChartBar,
labelKey: 'setting.statistic.statistics',
- searchNamespaces: ['setting.statistic'],
},
{
id: 'about',
icon: PhInfo,
labelKey: 'setting.tab.about',
- searchNamespaces: ['setting.about', 'setting.update'],
},
];
@@ -180,15 +162,6 @@ interface SettingsSearchResult {
label: string;
}
-function collectSearchText(value: unknown): string[] {
- if (typeof value === 'string') return [value];
- if (Array.isArray(value)) return value.flatMap(collectSearchText);
- if (value && typeof value === 'object') {
- return Object.values(value as Record
).flatMap(collectSearchText);
- }
- return [];
-}
-
const settingsSearchResults = computed(() => {
const query = settingsSearchQuery.value.trim().toLocaleLowerCase();
if (!query) return [];
@@ -198,10 +171,7 @@ const settingsSearchResults = computed(() => {
for (const tab of settingsTabs) {
const tabLabel = t(tab.labelKey);
- const searchableText = [
- tabLabel,
- ...tab.searchNamespaces.flatMap((namespace) => collectSearchText(tm(namespace))),
- ];
+ const searchableText = [tabLabel, ...settingsSearchKeys[tab.id].map((key) => t(key))];
for (const text of searchableText) {
const label = text.trim().replace(/\s+/g, ' ');
diff --git a/frontend/src/components/modals/settings/ai/AIFeatureSettings.vue b/frontend/src/components/modals/settings/ai/AIFeatureSettings.vue
index 7d65a7958..724291a54 100644
--- a/frontend/src/components/modals/settings/ai/AIFeatureSettings.vue
+++ b/frontend/src/components/modals/settings/ai/AIFeatureSettings.vue
@@ -8,6 +8,8 @@ import {
PhBroom,
PhMagnifyingGlass,
PhLightning,
+ PhFloppyDisk,
+ PhChatDots,
} from '@phosphor-icons/vue';
import {
TipBox,
@@ -16,6 +18,7 @@ import {
NestedSettingsContainer,
SubSettingItem,
TextAreaControl,
+ ToggleControl,
} from '@/components/settings';
import AIProfileSelector from './AIProfileSelector.vue';
import AIChatQuickPromptsSettings from './AIChatQuickPromptsSettings.vue';
@@ -121,13 +124,16 @@ async function clearAllChatSessions() {
/>
-
+ >
+
+
-
-
- {{ t('setting.ai.responsePreferences') }}
-
-
- {{ t('setting.ai.responsePreferencesDesc') }}
-
+
-
+
+
+
+
+
+
+
-
-
- {{ t('setting.database.dataDirectory') }}
-
-
{{ t('setting.database.directoryDescription') }}
-
- {{ t('setting.database.directoryCurrent') }} {{ status.data_directory }}
-
-
- {{ t('setting.database.directoryOverridden') }}
-
-
-
- {{ t('setting.database.directoryMigrationFailed') }} {{ status.last_error }}
+
+
+
+ {{ t('setting.database.directoryCurrent') }} {{ status.data_directory }}
-
-
- {{ t('setting.database.directoryPending') }} {{ status.pending_directory }}
+
+ {{ t('setting.database.directoryOverridden') }}
+
+
+
+ {{ t('setting.database.directoryMigrationFailed') }} {{ status.last_error }}
- {{ t('setting.database.directoryScheduled') }}
-
- {{ t('setting.database.directoryCancel') }}
-
-
-
-
-
- {{ t('setting.database.directoryBrowse') }}
-
-
- {{ t('setting.database.directoryChange') }}
-
-
-
-
+
+
+ {{ t('setting.database.directoryPending') }} {{ status.pending_directory }}
+
+
+ {{ t('setting.database.directoryScheduled') }}
+
+
+ {{ t('setting.database.directoryCancel') }}
+
+
+
+
+
+ {{ t('setting.database.directoryBrowse') }}
+
+
+ {{ t('setting.database.directoryChange') }}
+
+
+
+
+
diff --git a/frontend/src/components/modals/settings/reading/ArticleDisplaySettings.vue b/frontend/src/components/modals/settings/reading/ArticleDisplaySettings.vue
index d41d55d67..de2ece539 100644
--- a/frontend/src/components/modals/settings/reading/ArticleDisplaySettings.vue
+++ b/frontend/src/components/modals/settings/reading/ArticleDisplaySettings.vue
@@ -2,7 +2,17 @@
import { useI18n } from 'vue-i18n';
import { computed } from 'vue';
import { formatExactDateTime } from '@/utils/date';
-import { PhArticle, PhEyeSlash, PhImage, PhListNumbers, PhSquaresFour } from '@phosphor-icons/vue';
+import {
+ PhArticle,
+ PhBrowser,
+ PhCalendarBlank,
+ PhClock,
+ PhEyeSlash,
+ PhImage,
+ PhListNumbers,
+ PhSquaresFour,
+ PhTimer,
+} from '@phosphor-icons/vue';
import { SettingGroup, SettingWithToggle, SettingWithSelect } from '@/components/settings';
import '@/components/settings/styles.css';
import type { SettingsData } from '@/types/settings';
@@ -50,7 +60,7 @@ function updateSetting(key: keyof SettingsData, value: string | number | boolean
(), {
icon: undefined,
description: '',
required: false,
+ layout: 'row',
});
-
-
+
-
+
+
+
+
+
+
@@ -56,4 +66,12 @@ withDefaults(defineProps
(), {
.setting-item-action {
@apply shrink-0;
}
+
+.setting-item-column {
+ @apply flex-col items-stretch;
+}
+
+.setting-item-action-column {
+ @apply w-full;
+}
diff --git a/frontend/src/components/settings/base/SubSettingItem.vue b/frontend/src/components/settings/base/SubSettingItem.vue
index 90815e930..47e2ac1b5 100644
--- a/frontend/src/components/settings/base/SubSettingItem.vue
+++ b/frontend/src/components/settings/base/SubSettingItem.vue
@@ -7,6 +7,7 @@ interface Props {
description?: string;
required?: boolean;
customClass?: string;
+ layout?: 'row' | 'column';
}
withDefaults(defineProps(), {
@@ -14,13 +15,23 @@ withDefaults(defineProps(), {
description: '',
required: false,
customClass: '',
+ layout: 'row',
});
-
-
-
+
+
-
@@ -53,4 +64,8 @@ withDefaults(defineProps
(), {
@apply flex items-center sm:items-start gap-2 sm:gap-4 p-2 sm:p-2.5 bg-bg-tertiary;
border-radius: var(--ui-radius-surface);
}
+
+.sub-setting-item-column {
+ @apply items-stretch;
+}
diff --git a/frontend/src/composables/article/useArticleDetail.ts b/frontend/src/composables/article/useArticleDetail.ts
index c2310055e..c7ac06019 100644
--- a/frontend/src/composables/article/useArticleDetail.ts
+++ b/frontend/src/composables/article/useArticleDetail.ts
@@ -3,6 +3,11 @@ import { useAppStore } from '@/stores/app';
import { useI18n } from 'vue-i18n';
import { openInBrowser } from '@/utils/browser';
import type { Article } from '@/types/models';
+import {
+ hasArticleContent,
+ queryArticleContentImages,
+ queryArticleContentLinks,
+} from '@/utils/articleContentDom';
import { proxyImagesInHtml, isMediaCacheEnabled } from '@/utils/mediaProxy';
import { loadArticleContent, invalidateArticleContent } from '@/utils/articleContentCache';
@@ -374,7 +379,7 @@ export function useArticleDetail() {
// Works on both main content and translated content
function unwrapImagesFromLinks() {
// Process all links in prose content (both main content and translations)
- const links = document.querySelectorAll('.prose-content a, .prose a');
+ const links = queryArticleContentLinks();
const linksToProcess: HTMLAnchorElement[] = [];
// Collect links that contain images (check both direct children and nested)
@@ -414,15 +419,11 @@ export function useArticleDetail() {
unwrapImagesFromLinks();
// Get all images in prose content (use more specific selector)
- const proseContainers = document.querySelectorAll('[data-article-content] .prose-content');
-
- if (proseContainers.length === 0) {
+ if (!hasArticleContent()) {
return;
}
- const images = document.querySelectorAll(
- '[data-article-content] .prose-content img'
- );
+ const images = queryArticleContentImages();
// Process images if there are any
if (images.length > 0) {
@@ -464,11 +465,7 @@ export function useArticleDetail() {
}
// Collect all images from the article content
- const allImages = Array.from(
- document.querySelectorAll(
- '[data-article-content] .prose-content img'
- )
- )
+ const allImages = queryArticleContentImages()
.filter((img) => {
// Filter out small icons
return !(img.height <= 24 && img.height > 0);
@@ -554,7 +551,7 @@ export function useArticleDetail() {
// Works for dynamically added content (e.g., translations)
function attachLinkEventListeners() {
// Get all text-only links (no images) in prose content
- const links = document.querySelectorAll('.prose-content a, .prose a');
+ const links = queryArticleContentLinks();
links.forEach((link) => {
try {
diff --git a/frontend/src/composables/core/useSettings.generated.ts b/frontend/src/composables/core/useSettings.generated.ts
index 18e2156ca..aae63dbdf 100644
--- a/frontend/src/composables/core/useSettings.generated.ts
+++ b/frontend/src/composables/core/useSettings.generated.ts
@@ -131,6 +131,7 @@ export function generateInitialSettings(): SettingsData {
siyuan_endpoint: settingsDefaults.siyuan_endpoint,
siyuan_folder: settingsDefaults.siyuan_folder,
siyuan_notebook_id: settingsDefaults.siyuan_notebook_id,
+ startup_minimized: settingsDefaults.startup_minimized,
startup_on_boot: settingsDefaults.startup_on_boot,
summary_enabled: settingsDefaults.summary_enabled,
summary_length: settingsDefaults.summary_length,
@@ -285,6 +286,7 @@ export function parseSettingsData(data: Record): SettingsData {
siyuan_endpoint: data.siyuan_endpoint || settingsDefaults.siyuan_endpoint,
siyuan_folder: data.siyuan_folder || settingsDefaults.siyuan_folder,
siyuan_notebook_id: data.siyuan_notebook_id || settingsDefaults.siyuan_notebook_id,
+ startup_minimized: data.startup_minimized === 'true',
startup_on_boot: data.startup_on_boot === 'true',
summary_enabled: data.summary_enabled === 'true',
summary_length: data.summary_length || settingsDefaults.summary_length,
@@ -436,6 +438,7 @@ export function buildAutoSavePayload(settingsRef: Ref): Record {
+ it('indexes rendered setting labels instead of hidden status and modal copy', () => {
+ const keys = Object.values(settingsSearchKeys).flat();
+
+ expect(keys).toContain('setting.ai.aiChatEnabled');
+ expect(keys).toContain('setting.network.enableProxy');
+ expect(keys).not.toContain('setting.ai.aiTestFailed');
+ expect(keys).not.toContain('modal.feed.errorCertificate');
+ expect(keys).not.toContain('setting.database.directoryFailed');
+ });
+});
diff --git a/frontend/src/config/settingsSearch.ts b/frontend/src/config/settingsSearch.ts
new file mode 100644
index 000000000..6a87e140f
--- /dev/null
+++ b/frontend/src/config/settingsSearch.ts
@@ -0,0 +1,114 @@
+import type { TabName } from '@/types/settings';
+
+// Keep Settings search limited to labels that are rendered without opening a
+// dependent control. Status, error, confirmation, placeholder and modal copy
+// deliberately stay out of the index.
+export const settingsSearchKeys: Record = {
+ general: [
+ 'setting.general.application',
+ 'setting.general.startupOnBoot',
+ 'setting.general.closeToTray',
+ 'setting.general.theme',
+ 'setting.general.language',
+ 'setting.general.uiFontFamily',
+ 'setting.general.uiFontSize',
+ 'setting.update.updates',
+ 'setting.update.updateCheckEnabled',
+ 'setting.feed.refreshMode',
+ 'setting.database.dataManagement',
+ 'setting.database.dataDirectory',
+ 'setting.database.autoCleanup',
+ 'setting.database.mediaCacheEnabled',
+ 'setting.database.articleContentCacheCleanup',
+ 'setting.database.mediaCacheCleanup',
+ ],
+ reading: [
+ 'setting.tab.articleDisplay',
+ 'setting.reading.dateFormat',
+ 'setting.reading.timeFormat',
+ 'setting.reading.relativeTime',
+ 'setting.reading.defaultViewMode',
+ 'setting.reading.showArticlePreviewImages',
+ 'setting.reading.showFloatingToc',
+ 'setting.reading.showUnreadCounts',
+ 'setting.typography.layoutMode',
+ 'setting.tab.contentSettings',
+ 'setting.feed.enableFullTextFetch',
+ 'setting.reading.imageGalleryEnabled',
+ 'setting.tab.interactionSettings',
+ 'setting.reading.hoverMarkAsRead',
+ 'setting.reading.scrollMarkAsRead',
+ 'setting.reading.confirmMarkAsRead',
+ 'setting.reading.autoMarkRead',
+ 'setting.reading.rememberArticlePosition',
+ 'setting.reading.showHiddenArticles',
+ 'setting.tab.typography',
+ 'setting.typography.contentFontFamily',
+ 'setting.typography.contentFontSize',
+ 'setting.typography.contentLineHeight',
+ 'setting.tab.customization',
+ 'setting.customization.css',
+ ],
+ feeds: [
+ 'modal.feed.manageFeeds',
+ 'setting.feed.addFeed',
+ 'modal.tag.manageTags',
+ 'modal.discovery.feedDiscovery',
+ 'modal.discovery.discoverAllFeeds',
+ 'setting.database.dataManagement',
+ 'modal.opml.import',
+ 'modal.opml.export',
+ 'setting.database.cleanDatabase',
+ ],
+ content: [
+ 'setting.content.summary',
+ 'setting.content.enableSummary',
+ 'setting.content.translation',
+ 'setting.content.enableTranslation',
+ ],
+ ai: [
+ 'setting.ai.aiProfiles',
+ 'setting.ai.addProfile',
+ 'setting.ai.aiFeatures',
+ 'setting.ai.aiSearchEnabled',
+ 'setting.ai.aiChatEnabled',
+ 'setting.ai.aiUsage',
+ 'setting.ai.setUsageLimit',
+ ],
+ rules: [
+ 'modal.rule.rules',
+ 'setting.rule.addRule',
+ 'setting.rule.importRules',
+ 'setting.rule.exportRules',
+ ],
+ network: [
+ 'setting.network.proxySettings',
+ 'setting.network.enableProxy',
+ 'modal.feed.refreshSettings',
+ 'setting.feed.retryTimeout',
+ 'setting.network.networkSettings',
+ 'setting.network.reDetectNetwork',
+ ],
+ plugins: [
+ 'setting.rsshub.enabled',
+ 'setting.freshrss.integration',
+ 'setting.plugins.notion.integration',
+ 'setting.plugins.obsidian.integration',
+ 'setting.plugins.siyuan.integration',
+ 'setting.plugins.zotero.integration',
+ ],
+ shortcuts: ['setting.shortcut.shortcuts', 'setting.shortcut.shortcutsEnabled'],
+ statistics: [
+ 'setting.statistic.statistics',
+ 'setting.statistic.articlesRead',
+ 'setting.statistic.articlesViewed',
+ 'setting.statistic.articlesFavorited',
+ 'setting.statistic.aiSummaries',
+ 'setting.statistic.aiChats',
+ ],
+ about: [
+ 'setting.about.version',
+ 'setting.about.viewOnGitHub',
+ 'setting.update.checkForUpdates',
+ ],
+};
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 3c7078a89..b7b07dd2f 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -72,7 +72,15 @@ const en: TranslationMessages = {
markBelowAsRead: 'Mark Below as Read',
markedAllAsRead: 'All articles marked as read',
markedNArticlesAsRead: 'Marked {count} articles as read',
+ markedSelectedAsRead: 'Marked {count} selected articles as read',
+ markedSelectedAsUnread: 'Marked {count} selected articles as unread',
+ batchReadUpdateFailed: 'Failed to update the selected articles',
noArticlesToMark: 'No articles to mark',
+ selectArticles: 'Select articles',
+ selectArticle: 'Select article',
+ deselectArticle: 'Deselect article',
+ selectAllVisible: 'Select all visible articles',
+ selectedArticles: '{count} selected',
openArticle: 'Open Article',
openInBrowser: 'Open in Browser',
openInBrowserShortcut: 'Open in Browser',
@@ -1166,6 +1174,8 @@ const en: TranslationMessages = {
uiFontSizeDesc: 'Base font size for the application interface',
startupOnBoot: 'Start on System Boot',
startupOnBootDesc: 'Automatically start MrRSS when the computer starts',
+ startupMinimized: 'Start in System Tray',
+ startupMinimizedDesc: 'Keep the window hidden when MrRSS starts with the system',
theme: 'Theme',
themeDesc: 'Choose the preferred color scheme',
},
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 1653a4503..705f2114a 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -66,7 +66,15 @@ const zh: TranslationMessages = {
markBelowAsRead: '将以下标记为已读',
markedAllAsRead: '所有文章已标记为已读',
markedNArticlesAsRead: '已标记 {count} 篇文章为已读',
+ markedSelectedAsRead: '已将选中的 {count} 篇文章标记为已读',
+ markedSelectedAsUnread: '已将选中的 {count} 篇文章标记为未读',
+ batchReadUpdateFailed: '更新所选文章失败',
noArticlesToMark: '没有可标记的文章',
+ selectArticles: '多选文章',
+ selectArticle: '选择文章',
+ deselectArticle: '取消选择文章',
+ selectAllVisible: '选择当前全部文章',
+ selectedArticles: '已选择 {count} 篇',
openArticle: '打开文章',
openInBrowser: '在浏览器中打开',
openInBrowserShortcut: '在浏览器中打开',
@@ -1124,6 +1132,8 @@ const zh: TranslationMessages = {
uiFontSizeDesc: '应用界面的基础字体大小',
startupOnBoot: '开机自启动',
startupOnBootDesc: '在电脑启动时自动启动 MrRSS',
+ startupMinimized: '启动至系统托盘',
+ startupMinimizedDesc: '随系统启动时保持窗口隐藏',
theme: '主题',
themeDesc: '选择首选配色方案',
},
diff --git a/frontend/src/types/settings.generated.ts b/frontend/src/types/settings.generated.ts
index 7ca2639fb..65d459122 100644
--- a/frontend/src/types/settings.generated.ts
+++ b/frontend/src/types/settings.generated.ts
@@ -123,6 +123,7 @@ export interface SettingsData {
siyuan_endpoint: string;
siyuan_folder: string;
siyuan_notebook_id: string;
+ startup_minimized: boolean;
startup_on_boot: boolean;
summary_enabled: boolean;
summary_length: string;
diff --git a/frontend/src/utils/articleContentDom.test.ts b/frontend/src/utils/articleContentDom.test.ts
new file mode 100644
index 000000000..71c2c42ca
--- /dev/null
+++ b/frontend/src/utils/articleContentDom.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import {
+ hasArticleContent,
+ queryArticleContentImages,
+ queryArticleContentLinks,
+} from './articleContentDom';
+
+describe('article content DOM queries', () => {
+ it('excludes images and links rendered by adjacent AI chat UI', () => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const modal = document.querySelector('.modal-prose-content')!;
+ expect(hasArticleContent(modal)).toBe(true);
+ expect(queryArticleContentImages(modal).map((image) => image.id)).toEqual(['article-image']);
+ expect(queryArticleContentLinks(modal).map((link) => link.href)).toEqual([
+ 'https://example.com/article',
+ ]);
+ });
+});
diff --git a/frontend/src/utils/articleContentDom.ts b/frontend/src/utils/articleContentDom.ts
new file mode 100644
index 000000000..cfbf5755c
--- /dev/null
+++ b/frontend/src/utils/articleContentDom.ts
@@ -0,0 +1,13 @@
+const ARTICLE_CONTENT_SELECTOR = '[data-article-content] .prose-content';
+
+export function queryArticleContentImages(root: ParentNode = document): HTMLImageElement[] {
+ return Array.from(root.querySelectorAll(`${ARTICLE_CONTENT_SELECTOR} img`));
+}
+
+export function queryArticleContentLinks(root: ParentNode = document): HTMLAnchorElement[] {
+ return Array.from(root.querySelectorAll(`${ARTICLE_CONTENT_SELECTOR} a`));
+}
+
+export function hasArticleContent(root: ParentNode = document): boolean {
+ return root.querySelector(ARTICLE_CONTENT_SELECTOR) !== null;
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 7fc38a0ea..05ee2cb0c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -136,6 +136,7 @@ type Defaults struct {
SiyuanEndpoint string `json:"siyuan_endpoint"`
SiyuanFolder string `json:"siyuan_folder"`
SiyuanNotebookId string `json:"siyuan_notebook_id"`
+ StartupMinimized bool `json:"startup_minimized"`
StartupOnBoot bool `json:"startup_on_boot"`
SummaryEnabled bool `json:"summary_enabled"`
SummaryLength string `json:"summary_length"`
@@ -417,6 +418,8 @@ func GetString(key string) string {
return defaults.SiyuanFolder
case "siyuan_notebook_id":
return defaults.SiyuanNotebookId
+ case "startup_minimized":
+ return strconv.FormatBool(defaults.StartupMinimized)
case "startup_on_boot":
return strconv.FormatBool(defaults.StartupOnBoot)
case "summary_enabled":
diff --git a/internal/config/defaults.json b/internal/config/defaults.json
index f3895369d..56edd835c 100644
--- a/internal/config/defaults.json
+++ b/internal/config/defaults.json
@@ -117,6 +117,7 @@
"siyuan_endpoint": "http://127.0.0.1:6806",
"siyuan_folder": "/MrRSS",
"siyuan_notebook_id": "",
+ "startup_minimized": false,
"startup_on_boot": false,
"summary_enabled": true,
"summary_length": "medium",
diff --git a/internal/config/settings_keys.go b/internal/config/settings_keys.go
index 2f437f64d..aba5220cb 100644
--- a/internal/config/settings_keys.go
+++ b/internal/config/settings_keys.go
@@ -7,5 +7,5 @@ package config
// SettingsKeys returns all valid setting keys
func SettingsKeys() []string {
- return []string{"ai_api_key", "ai_chat_enabled", "ai_chat_profile_id", "ai_chat_quick_prompts", "ai_chat_response_preferences", "ai_chat_save_history", "ai_custom_headers", "ai_endpoint", "ai_model", "ai_search_enabled", "ai_search_profile_id", "ai_summary_profile_id", "ai_summary_prompt", "ai_translation_profile_id", "ai_translation_prompt", "ai_usage_limit", "ai_usage_tokens", "article_table_columns", "article_toolbar_layout", "auto_cleanup_enabled", "auto_mark_read_days", "auto_mark_read_enabled", "auto_show_all_content", "baidu_app_id", "baidu_secret_key", "close_to_tray", "confirm_mark_as_read", "content_font_family", "content_font_size", "content_line_height", "custom_css_file", "custom_translation_body_template", "custom_translation_enabled", "custom_translation_endpoint", "custom_translation_headers", "custom_translation_lang_mapping", "custom_translation_method", "custom_translation_name", "custom_translation_response_path", "custom_translation_timeout", "data_directory", "date_format", "deepl_api_key", "deepl_endpoint", "default_view_mode", "feed_drawer_expanded", "feed_drawer_pinned", "freshrss_api_password", "freshrss_auto_sync_interval", "freshrss_enabled", "freshrss_last_sync_time", "freshrss_provider", "freshrss_server_url", "freshrss_sync_on_startup", "freshrss_username", "full_text_fetch_enabled", "google_translate_endpoint", "hover_mark_as_read", "image_gallery_enabled", "language", "last_global_refresh", "last_network_test", "layout_mode", "max_article_age_days", "max_cache_size_mb", "max_concurrent_refreshes", "media_cache_enabled", "media_cache_max_age_days", "media_cache_max_size_mb", "media_proxy_fallback", "microsoft_api_key", "microsoft_endpoint", "microsoft_region", "miniflux_api_password", "miniflux_auto_sync_interval", "miniflux_enabled", "miniflux_last_sync_time", "miniflux_server_url", "miniflux_sync_on_startup", "miniflux_username", "network_bandwidth_mbps", "network_latency_ms", "network_speed", "notion_api_key", "notion_enabled", "notion_page_id", "obsidian_enabled", "obsidian_vault", "obsidian_vault_path", "proxy_enabled", "proxy_host", "proxy_password", "proxy_port", "proxy_type", "proxy_username", "refresh_mode", "relative_time", "remember_article_position", "retry_timeout_seconds", "rsshub_api_key", "rsshub_enabled", "rsshub_endpoint", "rules", "scroll_mark_as_read", "shortcuts", "shortcuts_enabled", "show_article_preview_images", "show_floating_toc", "show_hidden_articles", "show_unread_counts", "sidebar_category_order", "sidebar_pinned_items", "sidebar_sort_mode", "siyuan_api_token", "siyuan_enabled", "siyuan_endpoint", "siyuan_folder", "siyuan_notebook_id", "startup_on_boot", "summary_enabled", "summary_length", "summary_provider", "summary_trigger_mode", "target_language", "tencent_region", "tencent_secret_id", "tencent_secret_key", "theme", "time_format", "translation_enabled", "translation_only_mode", "translation_provider", "translation_trigger_mode", "ui_font_family", "ui_font_size", "update_check_enabled", "update_interval", "window_height", "window_maximized", "window_width", "window_x", "window_y", "zotero_api_key", "zotero_enabled", "zotero_user_id"}
+ return []string{"ai_api_key", "ai_chat_enabled", "ai_chat_profile_id", "ai_chat_quick_prompts", "ai_chat_response_preferences", "ai_chat_save_history", "ai_custom_headers", "ai_endpoint", "ai_model", "ai_search_enabled", "ai_search_profile_id", "ai_summary_profile_id", "ai_summary_prompt", "ai_translation_profile_id", "ai_translation_prompt", "ai_usage_limit", "ai_usage_tokens", "article_table_columns", "article_toolbar_layout", "auto_cleanup_enabled", "auto_mark_read_days", "auto_mark_read_enabled", "auto_show_all_content", "baidu_app_id", "baidu_secret_key", "close_to_tray", "confirm_mark_as_read", "content_font_family", "content_font_size", "content_line_height", "custom_css_file", "custom_translation_body_template", "custom_translation_enabled", "custom_translation_endpoint", "custom_translation_headers", "custom_translation_lang_mapping", "custom_translation_method", "custom_translation_name", "custom_translation_response_path", "custom_translation_timeout", "data_directory", "date_format", "deepl_api_key", "deepl_endpoint", "default_view_mode", "feed_drawer_expanded", "feed_drawer_pinned", "freshrss_api_password", "freshrss_auto_sync_interval", "freshrss_enabled", "freshrss_last_sync_time", "freshrss_provider", "freshrss_server_url", "freshrss_sync_on_startup", "freshrss_username", "full_text_fetch_enabled", "google_translate_endpoint", "hover_mark_as_read", "image_gallery_enabled", "language", "last_global_refresh", "last_network_test", "layout_mode", "max_article_age_days", "max_cache_size_mb", "max_concurrent_refreshes", "media_cache_enabled", "media_cache_max_age_days", "media_cache_max_size_mb", "media_proxy_fallback", "microsoft_api_key", "microsoft_endpoint", "microsoft_region", "miniflux_api_password", "miniflux_auto_sync_interval", "miniflux_enabled", "miniflux_last_sync_time", "miniflux_server_url", "miniflux_sync_on_startup", "miniflux_username", "network_bandwidth_mbps", "network_latency_ms", "network_speed", "notion_api_key", "notion_enabled", "notion_page_id", "obsidian_enabled", "obsidian_vault", "obsidian_vault_path", "proxy_enabled", "proxy_host", "proxy_password", "proxy_port", "proxy_type", "proxy_username", "refresh_mode", "relative_time", "remember_article_position", "retry_timeout_seconds", "rsshub_api_key", "rsshub_enabled", "rsshub_endpoint", "rules", "scroll_mark_as_read", "shortcuts", "shortcuts_enabled", "show_article_preview_images", "show_floating_toc", "show_hidden_articles", "show_unread_counts", "sidebar_category_order", "sidebar_pinned_items", "sidebar_sort_mode", "siyuan_api_token", "siyuan_enabled", "siyuan_endpoint", "siyuan_folder", "siyuan_notebook_id", "startup_minimized", "startup_on_boot", "summary_enabled", "summary_length", "summary_provider", "summary_trigger_mode", "target_language", "tencent_region", "tencent_secret_id", "tencent_secret_key", "theme", "time_format", "translation_enabled", "translation_only_mode", "translation_provider", "translation_trigger_mode", "ui_font_family", "ui_font_size", "update_check_enabled", "update_interval", "window_height", "window_maximized", "window_width", "window_x", "window_y", "zotero_api_key", "zotero_enabled", "zotero_user_id"}
}
diff --git a/internal/config/settings_schema.json b/internal/config/settings_schema.json
index d160d8a41..df27c799b 100644
--- a/internal/config/settings_schema.json
+++ b/internal/config/settings_schema.json
@@ -60,6 +60,13 @@
"encrypted": false,
"frontend_key": "startupOnBoot"
},
+ "startup_minimized": {
+ "type": "bool",
+ "default": false,
+ "category": "general",
+ "encrypted": false,
+ "frontend_key": "startupMinimized"
+ },
"close_to_tray": {
"type": "bool",
"default": true,
diff --git a/internal/handlers/article/article_bulk.go b/internal/handlers/article/article_bulk.go
index 705754dcf..643e3dc32 100644
--- a/internal/handlers/article/article_bulk.go
+++ b/internal/handlers/article/article_bulk.go
@@ -2,6 +2,8 @@ package article
import (
"context"
+ "encoding/json"
+ "errors"
"log"
"net/http"
"strconv"
@@ -11,6 +13,13 @@ import (
"MrRSS/internal/handlers/response"
)
+const maxBatchReadArticles = 5000
+
+type BatchReadRequest struct {
+ IDs []int64 `json:"ids"`
+ Read bool `json:"read"`
+}
+
// HandleGetUnreadCounts returns unread counts for all feeds.
// @Summary Get unread counts
// @Description Get total unread count and per-feed unread counts
@@ -180,6 +189,65 @@ func HandleMarkAllAsRead(h *core.Handler, w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusOK)
}
+// HandleMarkArticlesRead updates the read state of an explicit article selection.
+// @Summary Mark selected articles as read or unread
+// @Description Update the read state of up to 5000 selected articles and sync supported reader services
+// @Tags articles
+// @Accept json
+// @Produce json
+// @Param request body BatchReadRequest true "Selected article IDs and target read state"
+// @Success 200 {object} map[string]interface{}
+// @Failure 400 {object} map[string]string
+// @Failure 405 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Router /articles/read-batch [post]
+func HandleMarkArticlesRead(h *core.Handler, w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ response.Error(w, nil, http.StatusMethodNotAllowed)
+ return
+ }
+
+ var request BatchReadRequest
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&request); err != nil {
+ response.Error(w, errors.New("invalid batch read request"), http.StatusBadRequest)
+ return
+ }
+ if len(request.IDs) == 0 || len(request.IDs) > maxBatchReadArticles {
+ response.Error(w, errors.New("article selection must contain between 1 and 5000 IDs"), http.StatusBadRequest)
+ return
+ }
+
+ ids := make([]int64, 0, len(request.IDs))
+ seen := make(map[int64]struct{}, len(request.IDs))
+ for _, id := range request.IDs {
+ if id <= 0 {
+ response.Error(w, errors.New("article IDs must be positive"), http.StatusBadRequest)
+ return
+ }
+ if _, exists := seen[id]; exists {
+ continue
+ }
+ seen[id] = struct{}{}
+ ids = append(ids, id)
+ }
+
+ syncReqs, err := h.DB.MarkArticlesReadWithSync(ids, request.Read)
+ if err != nil {
+ response.Error(w, err, http.StatusInternalServerError)
+ return
+ }
+ if len(syncReqs) > 0 {
+ go performImmediateBulkSync(h, syncReqs)
+ }
+
+ response.JSON(w, map[string]interface{}{
+ "success": true,
+ "updated": len(ids),
+ })
+}
+
// HandleClearReadLater removes all articles from the read later list.
// @Summary Clear read-later list
// @Description Remove all articles from the read-later list
diff --git a/internal/handlers/article/article_handlers_test.go b/internal/handlers/article/article_handlers_test.go
index 8075c8ae5..6601c31b4 100644
--- a/internal/handlers/article/article_handlers_test.go
+++ b/internal/handlers/article/article_handlers_test.go
@@ -242,6 +242,55 @@ func TestArticleActions_MarkRead_Favorite_Hide_ReadLater(t *testing.T) {
}
}
+func TestHandleMarkArticlesRead(t *testing.T) {
+ h := setupHandler(t)
+ feedID, err := h.DB.AddFeed(&models.Feed{Title: "Batch", URL: "http://batch"})
+ if err != nil {
+ t.Fatalf("AddFeed: %v", err)
+ }
+ articles := []*models.Article{
+ {FeedID: feedID, Title: "first", URL: "http://batch/1", PublishedAt: time.Now()},
+ {FeedID: feedID, Title: "second", URL: "http://batch/2", PublishedAt: time.Now()},
+ }
+ if err := h.DB.SaveArticles(context.Background(), articles); err != nil {
+ t.Fatalf("SaveArticles: %v", err)
+ }
+ saved, err := h.DB.GetArticles("", feedID, "", true, 10, 0)
+ if err != nil || len(saved) != 2 {
+ t.Fatalf("GetArticles: %v (count %d)", err, len(saved))
+ }
+
+ body := fmt.Sprintf(`{"ids":[%d,%d,%d],"read":true}`, saved[0].ID, saved[1].ID, saved[0].ID)
+ req := httptest.NewRequest(http.MethodPost, "/api/articles/read-batch", strings.NewReader(body))
+ recorder := httptest.NewRecorder()
+ article.HandleMarkArticlesRead(h, recorder, req)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
+ }
+ var result struct {
+ Updated int `json:"updated"`
+ }
+ if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if result.Updated != 2 {
+ t.Fatalf("updated = %d, want 2 unique articles", result.Updated)
+ }
+ for _, savedArticle := range saved {
+ updated, err := h.DB.GetArticleByID(savedArticle.ID)
+ if err != nil || updated == nil || !updated.IsRead {
+ t.Fatalf("article %d was not marked read: %v", savedArticle.ID, err)
+ }
+ }
+
+ invalid := httptest.NewRequest(http.MethodPost, "/api/articles/read-batch", strings.NewReader(`{"ids":[],"read":false}`))
+ invalidRecorder := httptest.NewRecorder()
+ article.HandleMarkArticlesRead(h, invalidRecorder, invalid)
+ if invalidRecorder.Code != http.StatusBadRequest {
+ t.Fatalf("empty selection status = %d, want 400", invalidRecorder.Code)
+ }
+}
+
func TestHandleReloadArticleContentClearsOnlyArticleContent(t *testing.T) {
h := setupHandler(t)
feedID, err := h.DB.AddFeed(&models.Feed{Title: "Reload Feed", URL: "http://example.com/feed"})
diff --git a/internal/handlers/settings/settings_base.go b/internal/handlers/settings/settings_base.go
index f92bdf879..dfa9caced 100644
--- a/internal/handlers/settings/settings_base.go
+++ b/internal/handlers/settings/settings_base.go
@@ -135,6 +135,7 @@ var AllSettings = []SettingDef{
{Key: "siyuan_endpoint", Encrypted: false},
{Key: "siyuan_folder", Encrypted: false},
{Key: "siyuan_notebook_id", Encrypted: false},
+ {Key: "startup_minimized", Encrypted: false},
{Key: "startup_on_boot", Encrypted: false},
{Key: "summary_enabled", Encrypted: false},
{Key: "summary_length", Encrypted: false},
diff --git a/internal/routes/article_routes.go b/internal/routes/article_routes.go
index 2c1510f9e..b1f96d0a1 100644
--- a/internal/routes/article_routes.go
+++ b/internal/routes/article_routes.go
@@ -16,6 +16,7 @@ func registerArticleRoutes(mux *http.ServeMux, h *core.Handler) {
mux.HandleFunc("/api/articles/images", func(w http.ResponseWriter, r *http.Request) { article.HandleImageGalleryArticles(h, w, r) })
mux.HandleFunc("/api/articles/filter", func(w http.ResponseWriter, r *http.Request) { article.HandleFilteredArticles(h, w, r) })
mux.HandleFunc("/api/articles/read", func(w http.ResponseWriter, r *http.Request) { article.HandleMarkReadWithImmediateSync(h, w, r) })
+ mux.HandleFunc("/api/articles/read-batch", func(w http.ResponseWriter, r *http.Request) { article.HandleMarkArticlesRead(h, w, r) })
mux.HandleFunc("/api/articles/favorite", func(w http.ResponseWriter, r *http.Request) { article.HandleToggleFavoriteWithImmediateSync(h, w, r) })
mux.HandleFunc("/api/articles/mark-relative", func(w http.ResponseWriter, r *http.Request) { article.HandleMarkRelativeToArticle(h, w, r) })
mux.HandleFunc("/api/articles/toggle-hide", func(w http.ResponseWriter, r *http.Request) { article.HandleToggleHideArticle(h, w, r) })
diff --git a/internal/utils/startup.go b/internal/utils/startup.go
index eb46ea5f7..07d45c7a5 100644
--- a/internal/utils/startup.go
+++ b/internal/utils/startup.go
@@ -48,7 +48,7 @@ func DisableStartup() error {
// Windows implementation using registry
func enableStartupWindows(executable string) error {
- command := `"` + executable + `"`
+ command := `"` + executable + `" --start-minimized`
if dir := fileutil.CustomDataDir(); dir != "" && !fileutil.DesktopStorageManaged() {
// Windows paths cannot contain quotes; double trailing slashes before
// the closing quote (notably when the chosen directory is a drive root).
@@ -104,6 +104,7 @@ func enableStartupLinux(executable string) error {
if err != nil {
return err
}
+ execValue += " --start-minimized"
if dir := fileutil.CustomDataDir(); dir != "" && !fileutil.DesktopStorageManaged() {
quotedDir, err := desktopExec(dir)
if err != nil {
@@ -215,7 +216,7 @@ func enableStartupDarwin(executable string) error {
}
func startupDarwinArguments(executable string) string {
- args := "" + html.EscapeString(executable) + " "
+ args := "" + html.EscapeString(executable) + " \n\t\t--start-minimized "
if dir := fileutil.CustomDataDir(); dir != "" && !fileutil.DesktopStorageManaged() {
args += "\n\t\t--data-dir \n\t\t" + html.EscapeString(dir) + " "
}
diff --git a/internal/utils/startup_test.go b/internal/utils/startup_test.go
index 41442d92f..4f18faaa7 100644
--- a/internal/utils/startup_test.go
+++ b/internal/utils/startup_test.go
@@ -24,7 +24,7 @@ func TestLinuxStartupUsesPersistentAppImageAndXDGDirectory(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if !strings.Contains(string(body), "Exec="+want+"\n") || strings.Contains(string(body), ".mount_") {
+ if !strings.Contains(string(body), "Exec="+want+" --start-minimized\n") || strings.Contains(string(body), ".mount_") {
t.Fatalf("unexpected entry: %s", body)
}
if err := disableStartupLinux(); err != nil {
@@ -71,7 +71,15 @@ func TestLinuxStartupFallsBackToExecutable(t *testing.T) {
t.Fatal(err)
}
want, _ := desktopExec(executable)
- if !strings.Contains(string(body), "Exec="+want+"\n") {
+ if !strings.Contains(string(body), "Exec="+want+" --start-minimized\n") {
t.Fatalf("unexpected entry: %s", body)
}
}
+
+func TestStartupDarwinArgumentsRequestsMinimizedLaunch(t *testing.T) {
+ executable := filepath.Join(t.TempDir(), "MrRSS")
+ args := startupDarwinArguments(executable)
+ if !strings.Contains(args, "--start-minimized ") {
+ t.Fatalf("missing minimized launch argument: %s", args)
+ }
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index 801e5a057..e541a4e52 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -1,4 +1,4 @@
package version
// Version is the current application version
-const Version = "1.3.36"
+const Version = "1.3.37"
diff --git a/main-core.go b/main-core.go
index ec1f7bf13..d1891d703 100644
--- a/main-core.go
+++ b/main-core.go
@@ -31,7 +31,7 @@ import (
)
// @title MrRSS API
-// @version 1.3.36
+// @version 1.3.37
// @description MrRSS is a modern, cross-platform desktop RSS reader with auto-translation, smart feed discovery, and AI-powered summarization.
// @contact.name API Support
diff --git a/main.go b/main.go
index 45fddbcdb..9b03bfe4b 100644
--- a/main.go
+++ b/main.go
@@ -94,6 +94,14 @@ func APIMiddleware(combinedHandler *CombinedHandler) application.Middleware {
}
func main() {
+ startMinimizedRequested := false
+ for _, arg := range os.Args[1:] {
+ if arg == "--start-minimized" {
+ startMinimizedRequested = true
+ break
+ }
+ }
+
dataDirOption, err := fileutil.DataDirArgument(os.Args[1:])
if err != nil {
log.Fatal(err)
@@ -209,6 +217,9 @@ func main() {
var lastMaximized atomic.Bool
var hiddenToTray atomic.Bool
var hideAfterFullscreen atomic.Bool
+ startupMinimized, _ := db.GetSetting("startup_minimized")
+ startHidden := startMinimizedRequested && startupMinimized == "true"
+ hiddenToTray.Store(startHidden)
// API Routes
log.Println("Setting up API routes...")
@@ -385,6 +396,7 @@ func main() {
Windows: application.WindowsWindow{},
Linux: linuxWindowOptions,
BackgroundColour: backgroundColour,
+ Hidden: startHidden,
}
// Set position if restored from DB
@@ -399,7 +411,7 @@ func main() {
if !restoredFromDB {
mainWindow.Center()
}
- if restoredMaximized {
+ if restoredMaximized && !startHidden {
mainWindow.Maximise()
}
@@ -516,7 +528,7 @@ func main() {
})
// macOS also uses the status item for its unread indicator.
- if shouldCloseToTray() || runtime.GOOS == "darwin" {
+ if startHidden || shouldCloseToTray() || runtime.GOOS == "darwin" {
setupSystemTray()
}
if runtime.GOOS == "darwin" {
diff --git a/skills/mrrss-assistant/references/api.md b/skills/mrrss-assistant/references/api.md
index 98d058fb1..e9e525be9 100644
--- a/skills/mrrss-assistant/references/api.md
+++ b/skills/mrrss-assistant/references/api.md
@@ -6,7 +6,7 @@ Generated from `docs/SERVER_MODE/swagger.json`. Regenerate with:
python skills/mrrss-assistant/scripts/generate_api_reference.py docs/SERVER_MODE/swagger.json skills/mrrss-assistant/references/api.md
```
-- API version: `1.3.36`
+- API version: `1.3.37`
- API root: `{base_url}/api`
- Endpoint paths below are relative to the API root unless they already start with `/api/`.
diff --git a/tools/wait-for-dev-server/main.go b/tools/wait-for-dev-server/main.go
new file mode 100644
index 000000000..9369622ae
--- /dev/null
+++ b/tools/wait-for-dev-server/main.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+)
+
+const waitTimeout = 90 * time.Second
+
+func main() {
+ serverURL := os.Getenv("FRONTEND_DEVSERVER_URL")
+ if serverURL == "" {
+ port := os.Getenv("WAILS_VITE_PORT")
+ if port == "" {
+ port = "5173"
+ }
+ serverURL = "http://localhost:" + port
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), waitTimeout)
+ defer cancel()
+
+ client := &http.Client{
+ Timeout: time.Second,
+ Transport: &http.Transport{Proxy: nil},
+ }
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "invalid frontend development server URL: %v\n", err)
+ os.Exit(1)
+ }
+ response, err := client.Do(request)
+ if err == nil {
+ _, _ = io.Copy(io.Discard, response.Body)
+ _ = response.Body.Close()
+ if response.StatusCode < http.StatusInternalServerError {
+ fmt.Printf("Frontend development server is ready at %s\n", serverURL)
+ return
+ }
+ }
+
+ select {
+ case <-ctx.Done():
+ fmt.Fprintf(os.Stderr, "frontend development server did not become ready at %s within %s\n", serverURL, waitTimeout)
+ os.Exit(1)
+ case <-ticker.C:
+ }
+ }
+}
diff --git a/website/package-lock.json b/website/package-lock.json
index 3723af1fe..8869bd6a9 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mrrss-website",
- "version": "1.3.36",
+ "version": "1.3.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mrrss-website",
- "version": "1.3.36",
+ "version": "1.3.37",
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"vue": "^3.5.42"
diff --git a/website/package.json b/website/package.json
index 42ce835a6..cd800cfc4 100644
--- a/website/package.json
+++ b/website/package.json
@@ -1,6 +1,6 @@
{
"name": "mrrss-website",
- "version": "1.3.36",
+ "version": "1.3.37",
"description": "MrRSS - AI-Powered RSS Reader Landing Page",
"type": "module",
"scripts": {