diff --git a/AGENTS.md b/AGENTS.md index 778065595..bf02b21fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,14 +104,14 @@ Common entry points are below. Confirm they still exist in the current checkout; | Download Go dependencies | Repository root | `go mod download` | | Install locked frontend dependencies | `frontend/` | `npm ci` | | Discover build tasks | Repository root | `task --list` | -| Desktop development | Repository root | `task dev` | +| Desktop development | Repository root | `task dev` (uses the repository-pinned Wails CLI) | | Backend tests | Repository root | `go test -timeout=5m ./internal/...` | | Go static checks | Repository root | `go vet ./...` | | Frontend unit tests, single run | `frontend/` | `npm run test:unit` | | Frontend lint without autofix | `frontend/` | `npx --no-install eslint .` | | Frontend build | `frontend/` | `npm run build` | | End-to-end tests | `frontend/` | `npm run test:e2e` | -| Desktop build | Repository root | `wails3 build` or `task build` | +| Desktop build | Repository root | `task build` | - Start with tests for the affected packages or files, then run broader checks when the scope warrants them. Add regression tests for bug fixes and behavior changes; cover relevant failures and boundary conditions without relying on live services or real credentials. - Use a non-watching test command for automated verification. Inspect scripts before running them: lint and format scripts may apply fixes across the tree. diff --git a/CHANGELOG.md b/CHANGELOG.md index 11babb124..4c78432d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to MrRSS will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.37] - 2026-09-21 + +### Added + +- Select articles in normal, card, and table layouts, select all currently visible articles, and batch-mark the selection as read or unread through a bounded API that preserves reader-service synchronization. (#1210) (@baifengs) +- Optionally keep the desktop window hidden in the system tray when the application starts with the operating system, while preserving normal visibility for manual launches. (#1215) (@EnterMan123) +- Show an unread-state badge on multimedia gallery cards and remove it as soon as the article is marked read. (#1214) (@EnterMan123) + +### Changed + +- Align the data-directory and chat-response-preference layouts with neighboring settings, present chat-history retention as a nested setting, and use distinct icons for consecutive article-display options. +- Pin local Wails tasks to the runtime version, install frontend dependencies from the lockfile, and wait for the Vite server before launching the desktop process in development mode. + +### Fixed + +- Scope card-view image discovery to article content so AI chat icons do not enter the image viewer. (#1212) (@EnterMan123) +- Close article context menus when clicking modal overlays or other content that stops bubbling events. (#1213) (@EnterMan123) +- Limit settings search to visible setting labels so hidden modal text, status messages, and placeholders do not produce unreachable results. (#1216) (@EnterMan123) + ## [1.3.36] - 2026-09-20 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 781577d10..0db2a5bda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ This project and everyone participating in it is governed by our [Code of Conduc - Go 1.27 or higher - Node.js (LTS version) -- Wails CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117` +- Wails CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20` **Linux only:** Install system dependencies: diff --git a/README.md b/README.md index 613adfe57..5f2789102 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ English | 简体中文

-[![Version](https://img.shields.io/badge/version-1.3.36-blue.svg)](https://github.com/DevXDojo/MrRSS/releases) +[![Version](https://img.shields.io/badge/version-1.3.37-blue.svg)](https://github.com/DevXDojo/MrRSS/releases) [![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE) [![Go](https://img.shields.io/badge/Go-1.27+-00ADD8?logo=go)](https://go.dev/) [![Wails](https://img.shields.io/badge/Wails-v3%20alpha-red)](https://wails.io/) @@ -105,7 +105,7 @@ sudo apt-get install libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc pkg-c 3. **Install Wails v3 CLI** ```bash - go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117 + go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20 ``` 4. **Build the application** diff --git a/README_zh.md b/README_zh.md index 2ce870b60..14477fc6f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,7 +8,7 @@ English | 简体中文

-[![Version](https://img.shields.io/badge/version-1.3.36-blue.svg)](https://github.com/DevXDojo/MrRSS/releases) +[![Version](https://img.shields.io/badge/version-1.3.37-blue.svg)](https://github.com/DevXDojo/MrRSS/releases) [![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE) [![Go](https://img.shields.io/badge/Go-1.27+-00ADD8?logo=go)](https://go.dev/) [![Wails](https://img.shields.io/badge/Wails-v3%20alpha-red)](https://wails.io/) @@ -105,7 +105,7 @@ sudo apt-get install libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc pkg-c 3. **安装 Wails v3 CLI** ```bash - go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117 + go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20 ``` 4. **构建应用** diff --git a/Taskfile.yml b/Taskfile.yml index a8557b0a4..e647e6b66 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -13,6 +13,8 @@ vars: APP_NAME: "MrRSS" BIN_DIR: "build/bin" VITE_PORT: '{{.WAILS_VITE_PORT | default 5173}}' + # Keep this version synchronized with github.com/wailsapp/wails/v3 in go.mod. + WAILS3: go run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20 tasks: build: @@ -33,7 +35,7 @@ tasks: dev: summary: Runs the application in development mode (monitoring disabled) cmds: - - wails3 dev -config ./build/config.yml + - '{{.WAILS3}} dev -config ./build/config.yml' env: MRRSS_DEBUG: "1" diff --git a/build/README.md b/build/README.md index c91a15b01..bf8c336bd 100644 --- a/build/README.md +++ b/build/README.md @@ -7,8 +7,8 @@ This document describes how to build and package MrRSS using the Wails v3 build ### Required Tools - **Go 1.27+**: [https://golang.org/dl/](https://golang.org/dl/) -- **Node.js 18+**: [https://nodejs.org/](https://nodejs.org/) -- **Wails CLI v3**: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117` +- **Node.js 22.22.2 LTS or 24.15+**: [https://nodejs.org/](https://nodejs.org/) +- **Wails CLI v3**: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20` - **Task**: [https://taskfile.dev/installation/](https://taskfile.dev/installation/) ### Platform-Specific Dependencies diff --git a/build/Taskfile.yml b/build/Taskfile.yml index 32a8d94c1..53c3f1649 100644 --- a/build/Taskfile.yml +++ b/build/Taskfile.yml @@ -22,7 +22,7 @@ tasks: - sh: npm version msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/" cmds: - - npm install + - npm ci build:frontend: label: build:frontend (DEV={{.DEV}}) @@ -60,7 +60,7 @@ tasks: generates: - frontend/bindings/**/* cmds: - - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true + - "{{.WAILS3}} generate bindings -f '{{.BUILD_FLAGS}}' -clean=true" generate:icons: summary: Generates Windows `.ico` and Mac `.icns` files from an image @@ -75,8 +75,8 @@ tasks: # Wails supplies build/windows and build/darwin defaults for omitted output # flags. This task already runs from build/, so explicitly disable the # unrelated output while preserving the platform-specific source images. - - wails3 generate icons -input appicon.png -windowsfilename= -macfilename darwin/icons.icns - - wails3 generate icons -input windows/icon.png -windowsfilename windows/icon.ico -macfilename= + - '{{.WAILS3}} generate icons -input appicon.png -windowsfilename= -macfilename darwin/icons.icns' + - '{{.WAILS3}} generate icons -input windows/icon.png -windowsfilename windows/icon.ico -macfilename=' dev:frontend: summary: Runs the frontend in development mode @@ -90,7 +90,7 @@ tasks: summary: Updates the build assets dir: build cmds: - - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . + - '{{.WAILS3}} update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .' setup:docker: summary: Builds Docker image for cross-compilation (~800MB download) diff --git a/build/config.yml b/build/config.yml index 1f852d07b..f94e00e5e 100644 --- a/build/config.yml +++ b/build/config.yml @@ -1,5 +1,5 @@ # This file contains the configuration for this project. -# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets. +# When you update `info` or `fileAssociations`, run `task common:update:build-assets` to update the assets. # Note that this will overwrite any changes you have made to the assets. version: '3' @@ -11,7 +11,7 @@ info: description: "A modern, standalone RSS reader" # The application description copyright: "Copyright © 2026" # Copyright text comments: "Built with Wails" # Comments - version: "1.3.36" # The application version + version: "1.3.37" # The application version # iOS build configuration (uncomment to customise iOS project generation) # Note: Keys under `ios` OVERRIDE values under `info` when set. @@ -21,7 +21,7 @@ info: # # The display name shown under the app icon (CFBundleDisplayName/CFBundleName) # displayName: "MrRSS" # # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion) -# version: "1.3.36" +# version: "1.3.37" # # The company/organisation name for templates and project settings # company: "Ch3nyang" # # Additional comments to embed in Info.plist metadata @@ -49,11 +49,13 @@ dev_mode: - "*.ts" # The frontend directory will be excluded entirely by the setting above. git_ignore: true executes: - - cmd: wails3 build DEV=true + - cmd: task build DEV=true type: blocking - - cmd: wails3 task common:dev:frontend + - cmd: task common:dev:frontend type: background - - cmd: wails3 task run + - cmd: go run ./tools/wait-for-dev-server + type: blocking + - cmd: task run type: primary # File Associations diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 1ea370492..dee0b6feb 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -10,11 +10,11 @@ CFBundleIdentifier com.mrrss.app CFBundleVersion - 1.3.36 + 1.3.37 CFBundleGetInfoString Built with Wails CFBundleShortVersionString - 1.3.36 + 1.3.37 CFBundleIconFile icons LSMinimumSystemVersion diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index 88444ac80..cdad3ad7b 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -10,11 +10,11 @@ CFBundleIdentifier com.mrrss.app CFBundleVersion - 1.3.36 + 1.3.37 CFBundleGetInfoString Built with Wails CFBundleShortVersionString - 1.3.36 + 1.3.37 CFBundleIconFile icons LSMinimumSystemVersion diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml index 9b4f83389..c82a4fb7d 100644 --- a/build/darwin/Taskfile.yml +++ b/build/darwin/Taskfile.yml @@ -72,9 +72,9 @@ tasks: CFBundlePackageType APPL CFBundleShortVersionString - 1.3.36 + 1.3.37 CFBundleVersion - 1.3.36 + 1.3.37 LSMinimumSystemVersion 10.13 NSHighResolutionCapable diff --git a/build/darwin/create-dmg.sh b/build/darwin/create-dmg.sh index 740ee40e0..87c6c6164 100644 --- a/build/darwin/create-dmg.sh +++ b/build/darwin/create-dmg.sh @@ -12,7 +12,7 @@ set -e APP_NAME="MrRSS" # Get version from frontend/package.json if available, otherwise use default -VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' frontend/package.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)".*/\1/' || echo "1.3.36") +VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' frontend/package.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)".*/\1/' || echo "1.3.37") APP_PUBLISHER="Ch3nyang" APP_URL="https://github.com/DevXDojo/MrRSS" APP_DESCRIPTION="A Modern, Cross-Platform Desktop RSS Reader" diff --git a/build/ios/Info.dev.plist b/build/ios/Info.dev.plist index 00a3061df..1fe799230 100644 --- a/build/ios/Info.dev.plist +++ b/build/ios/Info.dev.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.36-dev + 1.3.37-dev CFBundleVersion - 1.3.36 + 1.3.37 LSRequiresIPhoneOS MinimumOSVersion diff --git a/build/ios/Info.plist b/build/ios/Info.plist index f586e85e8..3f0e32cad 100644 --- a/build/ios/Info.plist +++ b/build/ios/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.36 + 1.3.37 CFBundleVersion - 1.3.36 + 1.3.37 LSRequiresIPhoneOS MinimumOSVersion diff --git a/build/ios/build.sh b/build/ios/build.sh index 652610088..a26ac5279 100644 --- a/build/ios/build.sh +++ b/build/ios/build.sh @@ -4,8 +4,8 @@ set -e # Build configuration APP_NAME="MrRSS" BUNDLE_ID="com.mrrss.app" -VERSION="1.3.36" -BUILD_NUMBER="1.3.36" +VERSION="1.3.37" +BUILD_NUMBER="1.3.37" BUILD_DIR="build/ios" TARGET="simulator" diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml index af0f05c48..b655750fa 100644 --- a/build/linux/Taskfile.yml +++ b/build/linux/Taskfile.yml @@ -124,7 +124,7 @@ tasks: cmds: - cd {{.BIN_DIR}} && tar -czf {{.APP_NAME}}-{{.VERSION}}-linux-{{.ARCH}}.tar.gz {{.APP_NAME}} vars: - VERSION: '1.3.36' + VERSION: '1.3.37' ARCH: '{{.ARCH | default "amd64"}}' generate:dotdesktop: diff --git a/build/linux/create-appimage.sh b/build/linux/create-appimage.sh index 12f49ed61..c6302d239 100755 --- a/build/linux/create-appimage.sh +++ b/build/linux/create-appimage.sh @@ -13,7 +13,7 @@ set -e APP_NAME="MrRSS" # Get version from frontend/package.json if available, otherwise use default -VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' frontend/package.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)".*/\1/' || echo "1.3.36") +VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' frontend/package.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)".*/\1/' || echo "1.3.37") # Get architecture from environment variable or default to amd64 ARCH=${ARCH:-amd64} echo "Target architecture: ${ARCH}" diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml index ff461f854..56f26a1e4 100644 --- a/build/linux/nfpm/nfpm.yaml +++ b/build/linux/nfpm/nfpm.yaml @@ -6,7 +6,7 @@ name: "MrRSS" arch: ${GOARCH} platform: "linux" -version: "1.3.36" +version: "1.3.37" section: "default" priority: "extra" maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml index f2f086e5a..373b2cfde 100644 --- a/build/windows/Taskfile.yml +++ b/build/windows/Taskfile.yml @@ -106,7 +106,7 @@ tasks: deps: - task: generate:icon:ico cmds: - - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + - '{{.WAILS3}} generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso' vars: ARCH: '{{.ARCH | default ARCH}}' @@ -118,7 +118,7 @@ tasks: generates: - "windows/icon.ico" cmds: - - wails3 generate icons -input windows/icon.png -windowsfilename windows/icon.ico -macfilename= + - '{{.WAILS3}} generate icons -input windows/icon.png -windowsfilename windows/icon.ico -macfilename=' create:nsis:installer: summary: Creates an NSIS installer @@ -127,7 +127,7 @@ tasks: - task: build cmds: # Create the Microsoft WebView2 bootstrapper if it doesn't exist - - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows" + - '{{.WAILS3}} generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows"' - | {{if eq OS "windows"}} makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\build\bin\{{.APP_NAME}}.exe" installer.nsi @@ -151,7 +151,7 @@ tasks: deps: - task: build cmds: - - wails3 tool sign --input {{.BIN_DIR}}/{{.APP_NAME}}.exe {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + - '{{.WAILS3}} tool sign --input {{.BIN_DIR}}/{{.APP_NAME}}.exe {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}' preconditions: - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" diff --git a/build/windows/info.json b/build/windows/info.json index d92d96f6c..3209b0de6 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,10 +1,10 @@ { "fixed": { - "file_version": "1.3.36" + "file_version": "1.3.37" }, "info": { "0000": { - "ProductVersion": "1.3.36", + "ProductVersion": "1.3.37", "CompanyName": "Ch3nyang", "FileDescription": "MrRSS", "LegalCopyright": "Copyright © 2026", diff --git a/build/windows/installer.nsi b/build/windows/installer.nsi index 0559ba649..9101f2ca7 100644 --- a/build/windows/installer.nsi +++ b/build/windows/installer.nsi @@ -9,8 +9,8 @@ ; All paths in this script are relative to the script directory. !define APP_NAME "MrRSS" -!define APP_VERSION "1.3.36" -!define APP_VERSION_NUMERIC "1.3.36.0" ; NSIS requires X.X.X.X format +!define APP_VERSION "1.3.37" +!define APP_VERSION_NUMERIC "1.3.37.0" ; NSIS requires X.X.X.X format !define APP_PUBLISHER "Ch3nyang" !define APP_URL "https://github.com/DevXDojo/MrRSS" !define APP_DESCRIPTION "A Modern, Cross-Platform Desktop RSS Reader" diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh index 8096b669e..08afbfddf 100644 --- a/build/windows/nsis/wails_tools.nsh +++ b/build/windows/nsis/wails_tools.nsh @@ -14,7 +14,7 @@ !define INFO_PRODUCTNAME "MrRSS" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "1.3.36" + !define INFO_PRODUCTVERSION "1.3.37" !endif !ifndef INFO_COPYRIGHT !define INFO_COPYRIGHT "Copyright © 2026" diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index 2b79da19a..d587e2ba2 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + diff --git a/config/defaults.json b/config/defaults.json index f3895369d..56edd835c 100644 --- a/config/defaults.json +++ b/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/docs/SERVER_MODE/swagger.json b/docs/SERVER_MODE/swagger.json index b8d794c67..9193b79d8 100644 --- a/docs/SERVER_MODE/swagger.json +++ b/docs/SERVER_MODE/swagger.json @@ -12,7 +12,7 @@ "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.en.html" }, - "version": "1.3.36" + "version": "1.3.37" }, "host": "localhost:1234", "basePath": "/api", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b08a67307..934bc1754 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mrrss-frontend", - "version": "1.3.36", + "version": "1.3.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrrss-frontend", - "version": "1.3.36", + "version": "1.3.37", "dependencies": { "@eslint/js": "^10.0.1", "@phosphor-icons/vue": "^2.2.1", diff --git a/frontend/package.json b/frontend/package.json index 0ed57cb8c..fe8c4ed43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mrrss-frontend", "private": true, - "version": "1.3.36", + "version": "1.3.37", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/article/ArticleCardItem.vue b/frontend/src/components/article/ArticleCardItem.vue index 271d31ff5..1418c4064 100644 --- a/frontend/src/components/article/ArticleCardItem.vue +++ b/frontend/src/components/article/ArticleCardItem.vue @@ -2,7 +2,7 @@ import { withShortcut } from '@/composables/ui/shortcutBindings'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { useI18n } from 'vue-i18n'; -import { PhEyeSlash, PhStar, PhClockCountdown } from '@phosphor-icons/vue'; +import { PhCheckSquare, PhEyeSlash, PhSquare, PhStar, PhClockCountdown } from '@phosphor-icons/vue'; import type { Article } from '@/types/models'; import { useArticleDateFormat } from '@/composables/article/useArticleDateFormat'; import { getProxiedMediaUrl, isMediaCacheEnabled } from '@/utils/mediaProxy'; @@ -12,6 +12,8 @@ import { imageCache } from '@/utils/imageCache'; interface Props { article: Article; isActive: boolean; + selectionMode?: boolean; + selected?: boolean; } const props = defineProps(); @@ -121,7 +123,11 @@ function handleImageError(event: Event) {
+
{ diff --git a/frontend/src/components/article/ArticleItem.vue b/frontend/src/components/article/ArticleItem.vue index c86ee7b84..f5d2f170c 100644 --- a/frontend/src/components/article/ArticleItem.vue +++ b/frontend/src/components/article/ArticleItem.vue @@ -2,7 +2,7 @@ import { withShortcut } from '@/composables/ui/shortcutBindings'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { useI18n } from 'vue-i18n'; -import { PhEyeSlash, PhStar, PhClockCountdown } from '@phosphor-icons/vue'; +import { PhCheckSquare, PhEyeSlash, PhSquare, PhStar, PhClockCountdown } from '@phosphor-icons/vue'; import type { Article } from '@/types/models'; import { useArticleDateFormat } from '@/composables/article/useArticleDateFormat'; import { useArticleHoverRead } from '@/composables/article/useArticleHoverRead'; @@ -16,6 +16,8 @@ interface Props { article: Article; isActive: boolean; disabled?: boolean; + selectionMode?: boolean; + selected?: boolean; } const props = defineProps(); @@ -40,7 +42,7 @@ const compactMode = computed(() => { const { enter: handleMouseEnter, leave: handleMouseLeave } = useArticleHoverRead( () => props.article, (id) => emit('hoverMarkAsRead', id), - () => props.disabled === true + () => props.disabled === true || props.selectionMode === true ); // Check if article is from RSSHub feed - O(1) lookup using feedMap @@ -167,7 +169,11 @@ function handleImageError(event: Event) {
+
>(new Set()); +const selectionMode = ref(false); +const selectedArticleIds = ref>(new Set()); +const isApplyingSelection = ref(false); // Flag to control when scroll position should be restored const shouldRestoreScroll = ref(false); const pendingFeedArticleId = ref(null); @@ -260,7 +266,7 @@ const { showArticleContextMenu } = useArticleActions( const { onContextMenu: showSelectionContextMenu } = useArticleSelectionMenu(listRef); function handleArticleContextMenu(event: MouseEvent, article: Article): void { - if (showingPrevious.value) { + if (showingPrevious.value || selectionMode.value) { event.preventDefault(); return; } @@ -304,6 +310,78 @@ async function preserveRelativeReadPosition( const visibleArticles = computed(() => orderGroupedArticles(filteredArticles.value, store.articleGroupBy, store.articleSortOrder) ); +const selectedArticleCount = computed(() => selectedArticleIds.value.size); +const allVisibleArticlesSelected = computed( + () => + visibleArticles.value.length > 0 && + visibleArticles.value.every((article) => selectedArticleIds.value.has(article.id)) +); + +function enterSelectionMode(): void { + selectionMode.value = true; +} + +function exitSelectionMode(): void { + selectionMode.value = false; + selectedArticleIds.value = new Set(); +} + +function toggleArticleSelection(articleId: number): void { + const next = new Set(selectedArticleIds.value); + if (next.has(articleId)) next.delete(articleId); + else next.add(articleId); + selectedArticleIds.value = next; +} + +function toggleAllVisibleArticles(): void { + const visibleIds = visibleArticles.value.map((article) => article.id); + const next = new Set(selectedArticleIds.value); + if (allVisibleArticlesSelected.value) visibleIds.forEach((id) => next.delete(id)); + else visibleIds.forEach((id) => next.add(id)); + selectedArticleIds.value = next; +} + +function updateSelectedReadState(ids: Set, read: boolean): void { + for (const articles of [ + store.articles, + filteredArticlesFromServer.value, + aiSearchResults.value, + ]) { + articles.forEach((article) => { + if (ids.has(article.id)) article.is_read = read; + }); + } +} + +async function applySelectedReadState(read: boolean): Promise { + if (selectedArticleIds.value.size === 0 || isApplyingSelection.value) return; + + const ids = [...selectedArticleIds.value]; + isApplyingSelection.value = true; + try { + const result = await fetch('/api/articles/read-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, read }), + }); + if (!result.ok) throw new Error(`HTTP ${result.status}`); + + updateSelectedReadState(new Set(ids), read); + exitSelectionMode(); + await Promise.allSettled([store.fetchUnreadCounts(), store.fetchFilterCounts()]); + window.showToast( + t(read ? 'article.action.markedSelectedAsRead' : 'article.action.markedSelectedAsUnread', { + count: ids.length, + }), + 'success' + ); + } catch (error) { + console.error('Error updating selected articles:', error); + window.showToast(t('article.action.batchReadUpdateFailed'), 'error'); + } finally { + isApplyingSelection.value = false; + } +} const { displayedArticles, showingPrevious, showLoadingIndicator } = useArticleListTransition( visibleArticles, computed(() => store.isLoading) @@ -596,6 +674,21 @@ watch( } ); +watch( + () => [store.currentFeedId, store.currentCategory, store.currentFilter, isAISearchActive.value], + () => exitSelectionMode() +); + +watch( + () => visibleArticles.value.map((article) => article.id), + (visibleIds) => { + if (!selectionMode.value || selectedArticleIds.value.size === 0) return; + const visible = new Set(visibleIds); + const next = new Set([...selectedArticleIds.value].filter((id) => visible.has(id))); + if (next.size !== selectedArticleIds.value.size) selectedArticleIds.value = next; + } +); + // Keyboard/detail navigation can move the selection to a row that is outside // the rendered window; bring it back in before it is scrolled into view. watch( @@ -726,6 +819,10 @@ function onRefreshTooltipHide(): void { // Article selection and interaction function selectArticle(article: Article): void { if (showingPrevious.value) return; + if (selectionMode.value) { + toggleArticleSelection(article.id); + return; + } // Check if we should open in browser based on feed or global settings const feed = store.feeds.find((f) => f.id === article.feed_id); let openInBrowserMode = false; @@ -1215,10 +1312,15 @@ async function markAllVisibleAsRead(): Promise { :report-disabled=" showingPrevious || store.isLoading || isFilterLoading || visibleArticles.length === 0 " + :selection-disabled=" + showingPrevious || store.isLoading || isFilterLoading || visibleArticles.length === 0 + " + :selection-active="selectionMode" @sort="changeArticleSortOrder" @group="changeArticleGrouping" @filter="showFilterModal = true" @report="reportArticles = [...visibleArticles]" + @select="selectionMode ? exitSelectionMode() : enterSelectionMode()" />
{
+
+ +
+ + + +
+
@@ -1467,6 +1606,8 @@ async function markAllVisibleAsRead(): Promise { :article="article" :columns="tableColumns" :is-active="store.currentArticleId === article.id" + :selection-mode="selectionMode" + :selected="selectedArticleIds.has(article.id)" @click="selectArticle(article)" @contextmenu="(event) => handleArticleContextMenu(event, article)" @observe-element="(element) => observeListArticle(element, article.id)" @@ -1510,6 +1651,8 @@ async function markAllVisibleAsRead(): Promise { :is-active=" cardModalArticle?.id === article.id || recentlyClosedCardId === article.id " + :selection-mode="selectionMode" + :selected="selectedArticleIds.has(article.id)" @click="selectArticle(article)" @contextmenu="(e) => handleArticleContextMenu(e, article)" @observe-element="(element) => observeListArticle(element, article.id)" @@ -1562,6 +1705,8 @@ async function markAllVisibleAsRead(): Promise { :disabled="showingPrevious" :article="article" :is-active="store.currentArticleId === article.id" + :selection-mode="selectionMode" + :selected="selectedArticleIds.has(article.id)" @click="selectArticle(article)" @contextmenu="(e) => handleArticleContextMenu(e, article)" @observe-element="(element) => observeListArticle(element, article.id)" diff --git a/frontend/src/components/article/ArticleListMoreMenu.test.ts b/frontend/src/components/article/ArticleListMoreMenu.test.ts index d9cb2553b..5be06243b 100644 --- a/frontend/src/components/article/ArticleListMoreMenu.test.ts +++ b/frontend/src/components/article/ArticleListMoreMenu.test.ts @@ -28,18 +28,26 @@ describe('article list more menu', () => { const panel = menuPanel(); expect(panel.findAll('input[type="radio"]')).toHaveLength(5); expect(panel.get('input[value="newest"]').element).toBe(document.activeElement); - await panel.get('input[value="oldest"]').setValue(true); + const selectionButton = panel + .findAll('button') + .find((button) => button.text().includes('article.action.selectArticles'))!; + await selectionButton.trigger('click'); + expect(wrapper.emitted('select')).toHaveLength(1); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + + await trigger.trigger('click'); + const reopenedPanel = menuPanel(); + await reopenedPanel.get('input[value="oldest"]').setValue(true); expect(wrapper.emitted('sort')).toEqual([['oldest']]); await wrapper.setProps({ sortOrder: 'oldest' }); - await panel.get('input[value="feed"]').setValue(true); + await reopenedPanel.get('input[value="feed"]').setValue(true); expect(wrapper.emitted('group')).toEqual([['feed']]); await wrapper.setProps({ groupBy: 'feed' }); - expect(panel.findAll('input:checked').map((input) => input.attributes('value'))).toEqual([ - 'oldest', - 'feed', - ]); + expect( + reopenedPanel.findAll('input:checked').map((input) => input.attributes('value')) + ).toEqual(['oldest', 'feed']); expect(trigger.attributes('aria-expanded')).toBe('true'); - const filterButton = panel + const filterButton = reopenedPanel .findAll('button') .find((button) => button.text().includes('modal.filter.filter'))!; expect(filterButton.text()).toContain('2'); diff --git a/frontend/src/components/article/ArticleListMoreMenu.vue b/frontend/src/components/article/ArticleListMoreMenu.vue index ce0032078..1178b7817 100644 --- a/frontend/src/components/article/ArticleListMoreMenu.vue +++ b/frontend/src/components/article/ArticleListMoreMenu.vue @@ -8,6 +8,7 @@ import { PhSortAscending, PhSortDescending, PhSparkle, + PhCheckSquare, } from '@phosphor-icons/vue'; import type { ArticleSortOrder } from '@/stores/app'; import type { ArticleGroupBy } from '@/utils/articleGrouping'; @@ -17,12 +18,15 @@ const props = defineProps<{ groupBy: ArticleGroupBy; filterCount: number; reportDisabled?: boolean; + selectionDisabled?: boolean; + selectionActive?: boolean; }>(); const emit = defineEmits<{ sort: [value: ArticleSortOrder]; group: [value: ArticleGroupBy]; filter: []; report: []; + select: []; }>(); const { t } = useI18n(); const id = useId(); @@ -190,6 +194,23 @@ onBeforeUnmount(() => {
+ {{ 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.responsePreferencesDesc') }} -

+ -
+ + + + + + + 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