From 3c3d259b9076a8e935f52287157599abcfb514d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:28:28 +0000 Subject: [PATCH 01/59] chore(go-deps:backend)(deps): bump the go-dependencies group with 3 updates Bumps the go-dependencies group with 3 updates: [github.com/mmcdole/gofeed](https://github.com/mmcdole/gofeed), [github.com/wailsapp/wails/v3](https://github.com/wailsapp/wails) and [modernc.org/sqlite](https://gitlab.com/cznic/sqlite). Updates `github.com/mmcdole/gofeed` from 1.4.1 to 1.4.2 - [Release notes](https://github.com/mmcdole/gofeed/releases) - [Commits](https://github.com/mmcdole/gofeed/compare/v1.4.1...v1.4.2) Updates `github.com/wailsapp/wails/v3` from 3.0.0-beta.8 to 3.0.0-beta.11 - [Release notes](https://github.com/wailsapp/wails/releases) - [Commits](https://github.com/wailsapp/wails/compare/v3.0.0-beta.8...v3.0.0-beta.11) Updates `modernc.org/sqlite` from 1.56.0 to 1.57.0 - [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md) - [Commits](https://gitlab.com/cznic/sqlite/compare/v1.56.0...v1.57.0) --- updated-dependencies: - dependency-name: github.com/mmcdole/gofeed dependency-version: 1.4.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-dependencies - dependency-name: github.com/wailsapp/wails/v3 dependency-version: 3.0.0-beta.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go-dependencies - dependency-name: modernc.org/sqlite dependency-version: 1.57.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-dependencies ... Signed-off-by: dependabot[bot] --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index ca9cafe6..7228a2d1 100644 --- a/go.mod +++ b/go.mod @@ -15,13 +15,13 @@ require ( github.com/go-ego/gse v1.0.2 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/longbridgeapp/opencc v0.3.13 - github.com/mmcdole/gofeed v1.4.1 + github.com/mmcdole/gofeed v1.4.2 github.com/swaggo/http-swagger v1.3.4 - github.com/wailsapp/wails/v3 v3.0.0-beta.8 + github.com/wailsapp/wails/v3 v3.0.0-beta.11 golang.org/x/crypto v0.55.0 golang.org/x/net v0.58.0 golang.org/x/text v0.41.0 - modernc.org/sqlite v1.56.0 + modernc.org/sqlite v1.57.0 ) require ( diff --git a/go.sum b/go.sum index b66ddcf8..6ad3fee9 100644 --- a/go.sum +++ b/go.sum @@ -131,8 +131,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mmcdole/gofeed v1.4.1 h1:m7vd4YAukLvoqUQghaILKbqUquHyZq1jBYkeCke/Z3c= -github.com/mmcdole/gofeed v1.4.1/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs= +github.com/mmcdole/gofeed v1.4.2 h1:XFFOtsNNZg+zudjtMXb8BI0J/YdnSIGfjEpPgPnZib0= +github.com/mmcdole/gofeed v1.4.2/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs= github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ= github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -171,8 +171,8 @@ github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y= github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik= github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4= github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU= -github.com/wailsapp/wails/v3 v3.0.0-beta.8 h1:r5d+5ERZ6TYI/OPJnqq91Ugux+TkfplCURUi/+6vA/Q= -github.com/wailsapp/wails/v3 v3.0.0-beta.8/go.mod h1:A/OaL1mXOnwWynTJv4rZU89Wbk5q3rtq3C3qkx4rRN0= +github.com/wailsapp/wails/v3 v3.0.0-beta.11 h1:+DA9+DjHq/zV1Wkrg637WD0nSKly20w6jYbe/4LCwk4= +github.com/wailsapp/wails/v3 v3.0.0-beta.11/go.mod h1:zKZYhB3WjrN5LhJWbnOAVMN0Xf8qTozbw2nf5micKl4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= @@ -301,8 +301,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= -modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 465210a90dee7f1e060089d92d86922eb5b1de2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:28:34 +0000 Subject: [PATCH 02/59] chore(npm-deps:website)(deps-dev): bump vite Bumps the website-dependencies group in /website with 1 update: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `vite` from 8.2.1 to 8.2.2 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: website-dependencies ... Signed-off-by: dependabot[bot] --- website/package-lock.json | 158 +++++++++++++++++++++----------------- website/package.json | 2 +- 2 files changed, 89 insertions(+), 71 deletions(-) diff --git a/website/package-lock.json b/website/package-lock.json index f8221aec..ab2866ab 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-vue": "^6.0.8", "eslint": "^10.8.1", "prettier": "^3.9.6", - "vite": "^8.2.1" + "vite": "^8.2.2" } }, "node_modules/@babel/helper-string-parser": { @@ -254,9 +254,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.143.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", - "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", "dev": true, "license": "MIT", "funding": { @@ -275,10 +275,27 @@ "vue": ">=3.2.39" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", - "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", "cpu": [ "arm64" ], @@ -293,9 +310,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", "cpu": [ "arm64" ], @@ -310,9 +327,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", "cpu": [ "x64" ], @@ -327,9 +344,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", - "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", "cpu": [ "x64" ], @@ -344,9 +361,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", - "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", "cpu": [ "arm" ], @@ -361,9 +378,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", - "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", "cpu": [ "arm64" ], @@ -381,9 +398,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", - "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", "cpu": [ "arm64" ], @@ -401,9 +418,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", - "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", "cpu": [ "ppc64" ], @@ -421,9 +438,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", - "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", "cpu": [ "s390x" ], @@ -441,9 +458,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", - "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", "cpu": [ "x64" ], @@ -461,9 +478,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", - "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", "cpu": [ "x64" ], @@ -481,9 +498,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", - "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", "cpu": [ "arm64" ], @@ -498,9 +515,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", - "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", "cpu": [ "arm64" ], @@ -515,9 +532,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", - "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", "cpu": [ "x64" ], @@ -1922,13 +1939,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", - "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.143.0", + "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1938,20 +1955,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.3", - "@rolldown/binding-darwin-arm64": "1.2.3", - "@rolldown/binding-darwin-x64": "1.2.3", - "@rolldown/binding-freebsd-x64": "1.2.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", - "@rolldown/binding-linux-arm64-gnu": "1.2.3", - "@rolldown/binding-linux-arm64-musl": "1.2.3", - "@rolldown/binding-linux-ppc64-gnu": "1.2.3", - "@rolldown/binding-linux-s390x-gnu": "1.2.3", - "@rolldown/binding-linux-x64-gnu": "1.2.3", - "@rolldown/binding-linux-x64-musl": "1.2.3", - "@rolldown/binding-openharmony-arm64": "1.2.3", - "@rolldown/binding-win32-arm64-msvc": "1.2.3", - "@rolldown/binding-win32-x64-msvc": "1.2.3" + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" } }, "node_modules/semver": { @@ -2068,16 +2086,16 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -2094,7 +2112,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/website/package.json b/website/package.json index ba9f6283..b4642414 100644 --- a/website/package.json +++ b/website/package.json @@ -21,6 +21,6 @@ "@vitejs/plugin-vue": "^6.0.8", "eslint": "^10.8.1", "prettier": "^3.9.6", - "vite": "^8.2.1" + "vite": "^8.2.2" } } From b08169d4cb2a1fbe8be9951d329bc2a6c563b90b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:28:55 +0000 Subject: [PATCH 03/59] chore(npm-deps:frontend)(deps-dev): bump the frontend-dependencies group Bumps the frontend-dependencies group in /frontend with 4 updates: [@wailsio/runtime](https://github.com/wailsapp/wails/tree/HEAD/v3/internal/runtime/desktop/@wailsio/runtime), [cypress](https://github.com/cypress-io/cypress), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). Updates `@wailsio/runtime` from 3.0.0-beta.8 to 3.0.0-beta.11 - [Release notes](https://github.com/wailsapp/wails/releases) - [Commits](https://github.com/wailsapp/wails/commits/v3.0.0-beta.11/v3/internal/runtime/desktop/@wailsio/runtime) Updates `cypress` from 15.20.1 to 15.21.0 - [Release notes](https://github.com/cypress-io/cypress/releases) - [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md) - [Commits](https://github.com/cypress-io/cypress/compare/v15.20.1...v15.21.0) Updates `vite` from 8.2.1 to 8.2.2 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite) Updates `vitest` from 4.1.10 to 4.1.11 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest) --- updated-dependencies: - dependency-name: "@wailsio/runtime" dependency-version: 3.0.0-beta.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-dependencies - dependency-name: cypress dependency-version: 15.21.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-dependencies - dependency-name: vite dependency-version: 8.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-dependencies - dependency-name: vitest dependency-version: 4.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-dependencies ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 215 +++++++++++++++++++------------------ frontend/package.json | 8 +- 2 files changed, 117 insertions(+), 106 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1015cbe9..1b88cd5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,9 +28,9 @@ "@vitejs/plugin-vue": "^6.0.8", "@vue/eslint-config-prettier": "^10.2.0", "@vue/test-utils": "^2.4.11", - "@wailsio/runtime": "^3.0.0-beta.8", + "@wailsio/runtime": "^3.0.0-beta.11", "autoprefixer": "^10.5.4", - "cypress": "^15.20.1", + "cypress": "^15.21.0", "eslint": "^10.8.1", "eslint-plugin-vue": "^10.10.0", "husky": "^9.1.7", @@ -40,8 +40,8 @@ "postcss": "^8.5.26", "prettier": "^3.9.6", "typescript": "^7.0.2", - "vite": "^8.2.1", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "vue-eslint-parser": "^10.4.1" } }, @@ -131,7 +131,6 @@ "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", @@ -703,7 +702,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -752,7 +750,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -850,7 +847,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" @@ -863,7 +859,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2883,7 +2878,6 @@ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -2916,7 +2910,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2933,7 +2926,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2950,7 +2942,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2967,7 +2958,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2984,7 +2974,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3001,7 +2990,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3018,7 +3006,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3035,7 +3022,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3052,7 +3038,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3069,7 +3054,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3086,7 +3070,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3103,7 +3086,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3120,7 +3102,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3137,7 +3118,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3154,7 +3134,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3171,7 +3150,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3188,7 +3166,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3205,7 +3182,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3222,7 +3198,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3239,7 +3214,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3267,16 +3241,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3285,13 +3259,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3312,9 +3286,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3325,13 +3299,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3339,14 +3313,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3355,9 +3329,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3365,13 +3339,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3415,7 +3389,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" @@ -3469,6 +3442,7 @@ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-shared": "^8.2.1", "birpc": "^2.6.1", @@ -3480,7 +3454,8 @@ "version": "8.2.1", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@vue/eslint-config-prettier": { "version": "10.2.0", @@ -3567,9 +3542,9 @@ } }, "node_modules/@wailsio/runtime": { - "version": "3.0.0-beta.8", - "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.8.tgz", - "integrity": "sha512-c9PZJcOR9z1a6cxtBS2q5cygNajlxDE8Oxv/vvcTFYRbZSoOGBszq4uzlkPTOd0Bot1xkM81jnpaBgKWRpBh2g==", + "version": "3.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.11.tgz", + "integrity": "sha512-pMHts/Qunm4jPh67f+FCGb4YngMag7VSxol0Gw+8Ka5E/qn4StGX0w72haONH8bjgJZYnwgZvydIKIcoMsnAgA==", "dev": true, "license": "MIT" }, @@ -3589,7 +3564,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3854,6 +3828,7 @@ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/antfu" } @@ -3909,7 +3884,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -4058,6 +4032,27 @@ "node": ">=8" } }, + "node_modules/chrome-remote-interface": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz", + "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "2.11.x", + "ws": "^7.2.0" + }, + "bin": { + "chrome-remote-interface": "bin/client.js" + } + }, + "node_modules/chrome-remote-interface/node_modules/commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4351,13 +4346,12 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "15.20.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.20.1.tgz", - "integrity": "sha512-7GV3O7vQQG+EVVv9BGs6lCHY49x25jVi/z1+zdjuuq/o3eZeDwmGPJpRYnOWeGvQxTCtkydXWHBPhV8xuGXPLg==", + "version": "15.21.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.21.0.tgz", + "integrity": "sha512-6uPy5GUjao97t/QDIwlPyJz4JUqPpFIPq5lNazN95LSA3ynJoYC1sHZ3W+aj4f+K1OFGbMfno0+OX2Gx0OmlPA==", "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", @@ -4370,6 +4364,7 @@ "buffer": "^5.7.1", "cachedir": "^2.4.0", "chalk": "^4.1.0", + "chrome-remote-interface": "0.33.3", "ci-info": "^4.1.0", "cli-table3": "0.6.1", "commander": "^6.2.1", @@ -4784,7 +4779,6 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "devOptional": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -4844,7 +4838,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5764,7 +5757,8 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", @@ -6006,7 +6000,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7414,7 +7408,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/performance-now": { "version": "2.1.0", @@ -7496,7 +7491,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -7543,7 +7537,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8374,7 +8367,6 @@ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc" }, @@ -8515,17 +8507,16 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -8542,7 +8533,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8594,19 +8585,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8634,12 +8625,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8688,7 +8679,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", @@ -8718,7 +8708,6 @@ "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -8989,6 +8978,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c41f0fd8..e3fbb0cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,9 +47,9 @@ "@vitejs/plugin-vue": "^6.0.8", "@vue/eslint-config-prettier": "^10.2.0", "@vue/test-utils": "^2.4.11", - "@wailsio/runtime": "^3.0.0-beta.8", + "@wailsio/runtime": "^3.0.0-beta.11", "autoprefixer": "^10.5.4", - "cypress": "^15.20.1", + "cypress": "^15.21.0", "eslint": "^10.8.1", "eslint-plugin-vue": "^10.10.0", "husky": "^9.1.7", @@ -59,8 +59,8 @@ "postcss": "^8.5.26", "prettier": "^3.9.6", "typescript": "^7.0.2", - "vite": "^8.2.1", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "vue-eslint-parser": "^10.4.1" }, "lint-staged": { From 361be046e35f22ff3d2aaaca643cc3c404464062 Mon Sep 17 00:00:00 2001 From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:05:11 +0800 Subject: [PATCH 04/59] fix(search): explain AI results and open selected articles --- frontend/cypress/e2e/article-operations.cy.ts | 146 ++++++ .../src/components/article/AISearchBar.vue | 7 + .../components/article/ArticleDetailModal.vue | 7 +- .../src/components/article/ArticleList.vue | 184 ++++++- .../composables/article/useArticleDetail.ts | 16 +- .../composables/ui/useKeyboardShortcuts.ts | 14 +- frontend/src/i18n/locales/en.ts | 6 + frontend/src/i18n/locales/zh.ts | 6 + frontend/src/stores/app.ts | 16 + frontend/src/types/models.ts | 4 + internal/database/article_search_db.go | 476 +++++++++++++++--- internal/database/sqlite_test.go | 131 +++++ internal/handlers/ai/ai_search.go | 202 ++++---- 13 files changed, 991 insertions(+), 224 deletions(-) diff --git a/frontend/cypress/e2e/article-operations.cy.ts b/frontend/cypress/e2e/article-operations.cy.ts index 5b17e440..8cb0d71c 100644 --- a/frontend/cypress/e2e/article-operations.cy.ts +++ b/frontend/cypress/e2e/article-operations.cy.ts @@ -163,4 +163,150 @@ describe('Article Operations', () => { } }); }); + + it('should explain AI search results and keep list and card navigation in search context', () => { + const settingsState: Record = { + language: 'en-US', + theme: 'light', + layout_mode: 'normal', + default_view_mode: 'rendered', + ai_search_enabled: 'true', + translation_mode: 'off', + summary_enabled: 'false', + full_text_fetch_enabled: 'false', + update_check_enabled: 'false', + }; + const feed = { + id: 1, + title: 'Search Feed', + url: 'https://example.com/feed.xml', + category: '', + article_view_mode: 'global', + }; + const timelineArticle = { + id: 1, + feed_id: 1, + feed_title: feed.title, + title: 'Timeline article outside search results', + url: 'https://example.com/timeline', + published_at: '2026-08-20T00:00:00Z', + is_read: false, + is_favorite: false, + is_hidden: false, + is_read_later: false, + }; + const searchArticles = [101, 102, 103].map((id, index) => ({ + id, + feed_id: 1, + feed_title: feed.title, + title: `Search result ${index + 1}`, + url: `https://example.com/search/${id}`, + published_at: `2026-08-${23 - index}T00:00:00Z`, + is_read: false, + is_favorite: false, + is_hidden: false, + is_read_later: false, + relevance_score: 90 - index, + matched_terms: ['privacy'], + matched_fields: index === 0 ? ['title', 'summary'] : ['content'], + excerpt: `This privacy excerpt explains result ${index + 1}`, + })); + + cy.intercept('/api/**', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/settings', (req) => { + req.reply({ statusCode: 200, body: settingsState }); + }); + cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('searchFeeds'); + cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] }); + cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] }); + cy.intercept( + { method: 'GET', pathname: '/api/articles' }, + { statusCode: 200, body: [timelineArticle] } + ).as('timelineArticles'); + cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/progress', { statusCode: 200, body: { is_running: false } }); + cy.intercept('POST', '/api/ai/search', (req) => { + const noResults = req.body?.query === 'nothing matches'; + req.reply({ + statusCode: 200, + body: { + success: true, + articles: noResults ? [] : searchArticles, + total_count: noResults ? 0 : searchArticles.length, + }, + }); + }).as('aiSearch'); + cy.intercept('GET', '/api/articles/content*', (req) => { + req.reply({ + statusCode: 200, + body: { content: `

Body for search result ${req.query.id}

`, cached: true }, + }); + }).as('searchArticleContent'); + cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } }); + cy.intercept('POST', '/api/articles/favorite*', { + statusCode: 200, + body: { success: true }, + }); + cy.intercept('POST', '/api/articles/toggle-read-later*', { + statusCode: 200, + body: { success: true }, + }); + + cy.window().then((win) => win.localStorage.setItem('showOnlyUnread', 'true')); + cy.reload(); + cy.wait('@searchFeeds'); + cy.wait('@timelineArticles'); + + cy.get('input[placeholder="Describe what you want to find..."]').type('privacy'); + cy.contains('button', /^AI Search$/).click(); + cy.wait('@aiSearch'); + cy.contains('Title match').should('be.visible'); + cy.contains('Summary match').should('be.visible'); + cy.contains('This privacy excerpt explains result 1').should('be.visible'); + + cy.get('[data-article-id="101"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 101').should('be.visible'); + + // The next result is marked read when opened, but must remain selected and + // renderable while the unread-only preference is active. + cy.get('button[title="Next Article"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 102').should('be.visible'); + cy.get('[data-article-id="102"]').should('exist'); + + // Global shortcuts must use the same ordered search context instead of the + // separately paginated timeline. + cy.get('body').trigger('keydown', { key: 'j' }); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 103').should('be.visible'); + cy.get('button[title="Previous Article"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 102').should('be.visible'); + + cy.contains('button', /^Clear$/).click(); + cy.get('input[placeholder="Describe what you want to find..."]').type('nothing matches'); + cy.contains('button', /^AI Search$/).click(); + cy.wait('@aiSearch'); + cy.contains('No articles found matching your search').should('be.visible'); + + // Card mode uses ArticleDetailModal, which must use the same navigation + // context instead of hiding navigation for off-page search results. + cy.then(() => { + settingsState.layout_mode = 'card'; + }); + cy.reload(); + cy.wait('@searchFeeds'); + cy.wait('@timelineArticles'); + cy.get('input[placeholder="Describe what you want to find..."]').type('privacy'); + cy.contains('button', /^AI Search$/).click(); + cy.wait('@aiSearch'); + cy.get('.article-card-item[data-article-id="101"]').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 101').should('be.visible'); + cy.get('button[title="Next Article"]').should('be.visible').click(); + cy.wait('@searchArticleContent'); + cy.contains('Body for search result 102').should('be.visible'); + }); }); diff --git a/frontend/src/components/article/AISearchBar.vue b/frontend/src/components/article/AISearchBar.vue index d1d60708..517f1f24 100644 --- a/frontend/src/components/article/AISearchBar.vue +++ b/frontend/src/components/article/AISearchBar.vue @@ -60,6 +60,13 @@ async function performAISearch() { author: item.author as string, translated_title: item.translated_title as string, summary: item.summary as string, + original_summary: item.original_summary as string, + relevance_score: item.relevance_score as number, + matched_terms: Array.isArray(item.matched_terms) ? (item.matched_terms as string[]) : [], + matched_fields: Array.isArray(item.matched_fields) + ? (item.matched_fields as Article['matched_fields']) + : [], + excerpt: typeof item.excerpt === 'string' ? item.excerpt : '', })); hasResults.value = true; diff --git a/frontend/src/components/article/ArticleDetailModal.vue b/frontend/src/components/article/ArticleDetailModal.vue index 08100ef8..8f6fd6a8 100644 --- a/frontend/src/components/article/ArticleDetailModal.vue +++ b/frontend/src/components/article/ArticleDetailModal.vue @@ -151,14 +151,17 @@ async function exportToZotero() { } // Navigation +const navigationArticles = computed(() => store.navigableArticles); const currentArticleIndex = computed(() => { if (!props.article) return -1; - return store.articles.findIndex((a) => a.id === props.article.id); + return navigationArticles.value.findIndex((a) => a.id === props.article.id); }); const hasPreviousArticle = computed(() => currentArticleIndex.value > 0); const hasNextArticle = computed( - () => currentArticleIndex.value >= 0 && currentArticleIndex.value < store.articles.length - 1 + () => + currentArticleIndex.value >= 0 && + currentArticleIndex.value < navigationArticles.value.length - 1 ); // Load default view mode on mount diff --git a/frontend/src/components/article/ArticleList.vue b/frontend/src/components/article/ArticleList.vue index 75e4b60e..0c00fc1b 100644 --- a/frontend/src/components/article/ArticleList.vue +++ b/frontend/src/components/article/ArticleList.vue @@ -94,14 +94,18 @@ const isFilterLoading = computed(() => store.isFilterLoading); // Computed filtered articles - optimized to avoid excessive recomputation const filteredArticles = computed(() => { - const usesClientSideUnreadFilter = - activeFilters.value.length > 0 || (isAISearchActive.value && aiSearchResults.value.length > 0); + const usesClientSideUnreadFilter = activeFilters.value.length > 0 || isAISearchActive.value; // If AI search is active, use AI search results - if (isAISearchActive.value && aiSearchResults.value.length > 0) { + if (isAISearchActive.value) { let articles = aiSearchResults.value; if (store.showOnlyUnread) { - articles = articles.filter((article) => !article.is_read); + articles = articles.filter( + (article) => + !article.is_read || + temporarilyKeepArticles.value.has(article.id) || + article.id === store.currentArticleId + ); } return articles; } @@ -129,13 +133,66 @@ const filteredArticles = computed(() => { // AI Search handlers function handleAISearchResults(articles: Article[]) { + temporarilyKeepArticles.value.clear(); aiSearchResults.value = articles; isAISearchActive.value = true; + store.setArticleNavigationContext(articles); } function handleAISearchClear() { + temporarilyKeepArticles.value.clear(); aiSearchResults.value = []; isAISearchActive.value = false; + store.setArticleNavigationContext(null); + if ( + store.currentArticleId !== null && + !store.articles.some((article) => article.id === store.currentArticleId) + ) { + store.currentArticleId = null; + } +} + +interface SearchExcerptPart { + text: string; + matched: boolean; +} + +function searchExcerptParts(article: Article): SearchExcerptPart[] { + const excerpt = article.excerpt || ''; + const terms = (article.matched_terms || []) + .map((term) => term.replaceAll('%', ' ').replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .sort((a, b) => b.length - a.length); + if (!excerpt || terms.length === 0) return excerpt ? [{ text: excerpt, matched: false }] : []; + + const result: SearchExcerptPart[] = []; + const lowerExcerpt = excerpt.toLocaleLowerCase(); + let position = 0; + while (position < excerpt.length) { + let nextIndex = -1; + let nextTerm = ''; + for (const term of terms) { + const index = lowerExcerpt.indexOf(term.toLocaleLowerCase(), position); + if (index >= 0 && (nextIndex < 0 || index < nextIndex)) { + nextIndex = index; + nextTerm = term; + } + } + if (nextIndex < 0) { + result.push({ text: excerpt.slice(position), matched: false }); + break; + } + if (nextIndex > position) { + result.push({ text: excerpt.slice(position, nextIndex), matched: false }); + } + result.push({ text: excerpt.slice(nextIndex, nextIndex + nextTerm.length), matched: true }); + position = nextIndex + nextTerm.length; + } + return result; +} + +function searchFieldLabel(field: 'title' | 'summary' | 'content'): string { + return t(`aiSearch.matchFields.${field}`); } const { showArticleContextMenu } = useArticleActions(t, defaultViewMode, async () => { @@ -308,7 +365,34 @@ watch( } ); +// Keep detail navigation aligned with the currently visible AI result order, +// including the unread-only preference, without replacing the main timeline. +watch( + () => (isAISearchActive.value ? filteredArticles.value.map((article) => article.id) : []), + () => { + if (isAISearchActive.value) { + store.setArticleNavigationContext([...filteredArticles.value]); + } + } +); + +// Detail buttons and global shortcuts can move inside the search result set +// without going through selectArticle(). Keep only the newly selected result +// visible when the unread-only filter marks it as read. +watch( + () => store.currentArticleId, + (articleId) => { + if (!isAISearchActive.value) return; + if (articleId !== null && aiSearchResults.value.some((article) => article.id === articleId)) { + temporarilyKeepArticles.value.add(articleId); + } + } +); + onBeforeUnmount(() => { + if (isAISearchActive.value) { + store.setArticleNavigationContext(null); + } cleanupTranslation(); // Clear scroll throttle timer if (scrollThrottleTimer) { @@ -452,7 +536,7 @@ function selectArticle(article: Article): void { // Normal article selection - show in app // If switching from one article to another, remove the previous one from temp list - if (store.currentArticleId) { + if (store.currentArticleId && !isAISearchActive.value) { temporarilyKeepArticles.value.delete(store.currentArticleId); } @@ -1021,27 +1105,85 @@ async function markAllVisibleAsRead(): Promise {
- + class="min-w-0 overflow-hidden rounded-lg" + > + +
+
+ + {{ + t('aiSearch.relevanceScore', { score: Math.round(article.relevance_score || 0) }) + }} + + + {{ searchFieldLabel(field) }} + +
+

+ +

+
+
- +
+ +
+
+ + {{ + t('aiSearch.relevanceScore', { score: Math.round(article.relevance_score || 0) }) + }} + + + {{ searchFieldLabel(field) }} + +
+

+ +

+
+
diff --git a/frontend/src/composables/article/useArticleDetail.ts b/frontend/src/composables/article/useArticleDetail.ts index 64b3656e..1279ef5c 100644 --- a/frontend/src/composables/article/useArticleDetail.ts +++ b/frontend/src/composables/article/useArticleDetail.ts @@ -24,14 +24,16 @@ export function useArticleDetail() { const store = useAppStore(); const { t, locale } = useI18n(); + const navigationArticles = computed(() => store.navigableArticles); + const article = computed
(() => - store.articles.find((a) => a.id === store.currentArticleId) + navigationArticles.value.find((a) => a.id === store.currentArticleId) ); // Get current article index in the filtered list const currentArticleIndex = computed(() => { if (!store.currentArticleId) return -1; - return store.articles.findIndex((a) => a.id === store.currentArticleId); + return navigationArticles.value.findIndex((a) => a.id === store.currentArticleId); }); // Check if there's a previous article @@ -39,13 +41,15 @@ export function useArticleDetail() { // Check if there's a next article const hasNextArticle = computed( - () => currentArticleIndex.value >= 0 && currentArticleIndex.value < store.articles.length - 1 + () => + currentArticleIndex.value >= 0 && + currentArticleIndex.value < navigationArticles.value.length - 1 ); // Navigate to previous article function goToPreviousArticle() { if (hasPreviousArticle.value) { - const prevArticle = store.articles[currentArticleIndex.value - 1]; + const prevArticle = navigationArticles.value[currentArticleIndex.value - 1]; store.currentArticleId = prevArticle.id; markAsReadIfNeeded(prevArticle); scrollArticleIntoView(prevArticle.id); @@ -55,7 +59,7 @@ export function useArticleDetail() { // Navigate to next article function goToNextArticle() { if (hasNextArticle.value) { - const nextArticle = store.articles[currentArticleIndex.value + 1]; + const nextArticle = navigationArticles.value[currentArticleIndex.value + 1]; store.currentArticleId = nextArticle.id; markAsReadIfNeeded(nextArticle); scrollArticleIntoView(nextArticle.id); @@ -88,7 +92,7 @@ export function useArticleDetail() { } // Expose articles list and index for UI display - const articles = computed(() => store.articles); + const articles = computed(() => navigationArticles.value); const currentArticleIndexForDisplay = computed(() => currentArticleIndex.value + 1); // Get effective view mode based on feed settings and global settings diff --git a/frontend/src/composables/ui/useKeyboardShortcuts.ts b/frontend/src/composables/ui/useKeyboardShortcuts.ts index 140e22f5..135f096e 100644 --- a/frontend/src/composables/ui/useKeyboardShortcuts.ts +++ b/frontend/src/composables/ui/useKeyboardShortcuts.ts @@ -83,7 +83,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { } function navigateArticle(direction: number): void { - const articles = store.articles; + const articles = store.navigableArticles; if (!articles || articles.length === 0) return; const currentIndex = store.currentArticleId @@ -103,7 +103,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { } function selectArticleByIndex(index: number): void { - const article = store.articles[index]; + const article = store.navigableArticles[index]; if (!article) return; store.currentArticleId = article.id; @@ -126,7 +126,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { } function toggleCurrentArticleRead(): void { - const article = store.articles.find((a) => a.id === store.currentArticleId); + const article = store.navigableArticles.find((a) => a.id === store.currentArticleId); if (!article) return; const newState = !article.is_read; @@ -140,7 +140,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { } function toggleCurrentArticleFavorite(): void { - const article = store.articles.find((a) => a.id === store.currentArticleId); + const article = store.navigableArticles.find((a) => a.id === store.currentArticleId); if (!article) return; const newState = !article.is_favorite; @@ -152,7 +152,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { } function toggleCurrentArticleReadLater(): void { - const article = store.articles.find((a) => a.id === store.currentArticleId); + const article = store.navigableArticles.find((a) => a.id === store.currentArticleId); if (!article) return; const newState = !article.is_read_later; @@ -170,7 +170,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { } function openCurrentArticleInBrowser(): void { - const article = store.articles.find((a) => a.id === store.currentArticleId); + const article = store.navigableArticles.find((a) => a.id === store.currentArticleId); if (article && article.url) { openInBrowser(article.url); } @@ -372,7 +372,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) { navigateArticle(-1); break; case 'openArticle': - if (store.articles.length > 0 && !store.currentArticleId) { + if (store.navigableArticles.length > 0 && !store.currentArticleId) { selectArticleByIndex(0); } break; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 97e28877..6cbb8d34 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -137,6 +137,12 @@ const en: TranslationMessages = { foundResults: 'Found {count} articles', noResults: 'No articles found matching your search', placeholder: 'Describe what you want to find...', + relevanceScore: 'Relevance {score}', + matchFields: { + title: 'Title match', + summary: 'Summary match', + content: 'Content match', + }, searchFailed: 'AI search failed. Please check your AI settings.', showingResults: 'Showing AI search results', }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 726d3267..7ce00d29 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -132,6 +132,12 @@ const zh: TranslationMessages = { foundResults: '找到 {count} 篇文章', noResults: '没有找到符合搜索条件的文章', placeholder: '描述你想要查找的内容...', + relevanceScore: '相关度 {score}', + matchFields: { + title: '标题命中', + summary: '摘要命中', + content: '正文命中', + }, searchFailed: 'AI 搜索失败,请检查 AI 设置。', showingResults: '正在显示 AI 搜索结果', }, diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index e90a4de2..1f42c2b3 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -33,6 +33,7 @@ export interface AppState { showOnlyUnread: Ref; activeFilters: Ref; filteredArticlesFromServer: Ref; + articleNavigationContext: Ref; isFilterLoading: Ref; } @@ -57,6 +58,7 @@ export interface AppActions { startAutoRefresh: (minutes: number) => void; toggleShowOnlyUnread: () => void; setActiveFilters: (filters: FilterCondition[]) => void; + setArticleNavigationContext: (articles: Article[] | null) => void; } export const useAppStore = defineStore('app', () => { @@ -99,6 +101,13 @@ export const useAppStore = defineStore('app', () => { const showOnlyUnread = ref(localStorage.getItem('showOnlyUnread') === 'true'); const activeFilters = ref([]); const filteredArticlesFromServer = ref([]); + // A temporary ordered list used by result views (for example AI search). + // Keeping this in the store lets ArticleDetail resolve and navigate articles + // which are not part of the currently paginated timeline. + const articleNavigationContext = ref(null); + const navigableArticles = computed( + () => articleNavigationContext.value ?? articles.value + ); const isFilterLoading = ref(false); // Article view mode preferences (persisted across component mounts) @@ -782,6 +791,10 @@ export const useAppStore = defineStore('app', () => { filteredArticlesFromServer.value = articles; } + function setArticleNavigationContext(articles: Article[] | null): void { + articleNavigationContext.value = articles; + } + function setIsFilterLoading(loading: boolean): void { isFilterLoading.value = loading; } @@ -826,6 +839,8 @@ export const useAppStore = defineStore('app', () => { showOnlyUnread, activeFilters, filteredArticlesFromServer, + articleNavigationContext, + navigableArticles, isFilterLoading, articleViewModePreferences, @@ -855,6 +870,7 @@ export const useAppStore = defineStore('app', () => { toggleShowOnlyUnread, setActiveFilters, setFilteredArticlesFromServer, + setArticleNavigationContext, setIsFilterLoading, fetchTaskDetails, }; diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 088d9025..05fee289 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -28,6 +28,10 @@ export interface Article { summary?: string; // Cached AI-generated summary original_summary?: string; // Summary/description provided by the RSS item freshrss_item_id?: string; // FreshRSS/Google Reader item ID + relevance_score?: number; // AI search relevance score + matched_terms?: string[]; // Terms that matched this article + matched_fields?: Array<'title' | 'summary' | 'content'>; // Explainable match locations + excerpt?: string; // Sanitized AI search context excerpt } export interface Feed { diff --git a/internal/database/article_search_db.go b/internal/database/article_search_db.go index 09e5077e..faaf9ef3 100644 --- a/internal/database/article_search_db.go +++ b/internal/database/article_search_db.go @@ -3,10 +3,16 @@ package database import ( "database/sql" "fmt" + stdhtml "html" "log" + "sort" + "strings" "time" + "unicode" "MrRSS/internal/models" + + "golang.org/x/net/html" ) // GetImageGalleryArticles retrieves articles from image mode feeds with pagination. @@ -85,110 +91,432 @@ func (db *DB) GetImageGalleryArticles(feedID int64, category string, showHidden return articles, nil } -// SearchArticlesWithAI executes a search query with an AI-generated WHERE clause. -// The whereClause should be pre-validated to prevent SQL injection. -func (db *DB) SearchArticlesWithAI(whereClause string, limit int) ([]models.Article, error) { +// AISearchQuery contains bounded terms parsed from the AI response. AI output +// is always passed to SQLite as data and never executed as SQL. +type AISearchQuery struct { + Original string + Required []string + Optional []string + Patterns []string + Limit int +} + +// AISearchResult adds explainable ranking metadata without changing Article. +type AISearchResult struct { + Article models.Article + RelevanceScore float64 + MatchedTerms []string + MatchedFields []string + Excerpt string +} + +type aiSearchCandidate struct { + article models.Article + content string + summaryText string +} + +// SearchArticlesWithTerms performs a parameterized candidate lookup and then +// calculates a deterministic, explainable relevance score locally. +func (db *DB) SearchArticlesWithTerms(search AISearchQuery) ([]AISearchResult, error) { db.WaitForReady() + if search.Limit <= 0 { + search.Limit = 100 + } + if search.Limit > 500 { + search.Limit = 500 + } + + originalTerms := uniqueSearchTerms([]string{search.Original}, 1) + requiredTerms := uniqueSearchTerms(search.Required, 8) + plainTerms := uniqueSearchTerms(append(append([]string{}, originalTerms...), requiredTerms...), 12) + patternTerms := validSearchPatterns(search.Patterns, 4) + if len(plainTerms) == 0 && len(patternTerms) == 0 { + return []AISearchResult{}, nil + } - if limit <= 0 { - limit = 100 + conditions := make([]string, 0, len(plainTerms)+len(patternTerms)) + conditionArgs := make([]any, 0, (len(plainTerms)+len(patternTerms))*3) + appendCondition := func(term string, allowWildcard bool) { + pattern := searchLikePattern(term, allowWildcard) + conditions = append(conditions, `(LOWER(a.title) LIKE ? ESCAPE '\' OR LOWER(COALESCE(a.summary, '') || ' ' || COALESCE(a.original_summary, '')) LIKE ? ESCAPE '\' OR LOWER(COALESCE(c.content, '')) LIKE ? ESCAPE '\')`) + conditionArgs = append(conditionArgs, pattern, pattern, pattern) + } + for _, term := range plainTerms { + appendCondition(term, false) } - if limit > 500 { - limit = 500 + for _, term := range patternTerms { + appendCondition(term, true) } - // Build the complete query with the AI-generated WHERE clause - query := fmt.Sprintf(` - SELECT a.id, a.feed_id, a.title, a.url, a.image_url, a.audio_url, a.video_url, - a.published_at, a.is_read, a.is_favorite, a.is_hidden, a.is_read_later, - a.translated_title, a.summary, a.freshrss_item_id, f.title, a.author - FROM articles a - JOIN feeds f ON a.feed_id = f.id - WHERE %s - ORDER BY a.published_at DESC - LIMIT %d - `, whereClause, limit) + // Rank candidates in SQLite before applying LIMIT. Without this preliminary + // ordering, a broad AI-expanded term could fill the arbitrary row set and + // hide a later exact-title match from the deterministic Go ranker. + scoreParts := make([]string, 0, 1+len(requiredTerms)+len(patternTerms)) + scoreArgs := make([]any, 0, (1+len(requiredTerms)+len(patternTerms))*3) + appendScore := func(term string, allowWildcard bool, titleWeight, summaryWeight, contentWeight int) { + pattern := searchLikePattern(term, allowWildcard) + scoreParts = append(scoreParts, fmt.Sprintf( + `CASE WHEN LOWER(a.title) LIKE ? ESCAPE '\' THEN %d ELSE 0 END + CASE WHEN LOWER(COALESCE(a.summary, '') || ' ' || COALESCE(a.original_summary, '')) LIKE ? ESCAPE '\' THEN %d ELSE 0 END + CASE WHEN LOWER(COALESCE(c.content, '')) LIKE ? ESCAPE '\' THEN %d ELSE 0 END`, + titleWeight, summaryWeight, contentWeight, + )) + scoreArgs = append(scoreArgs, pattern, pattern, pattern) + } + for _, term := range originalTerms { + appendScore(term, false, 120, 70, 30) + } + for _, term := range requiredTerms { + appendScore(term, false, 24, 12, 5) + } + for _, term := range patternTerms { + appendScore(term, true, 30, 15, 7) + } + // Optional terms never broaden the candidate set. A bounded subset only + // breaks ties among articles which already match the user's query or a core + // expansion term. + for _, term := range uniqueSearchTerms(search.Optional, 6) { + appendScore(term, false, 6, 3, 1) + } + preliminaryScore := "0" + if len(scoreParts) > 0 { + preliminaryScore = strings.Join(scoreParts, " + ") + } + + candidateLimit := search.Limit * 2 + if candidateLimit < 200 { + candidateLimit = 200 + } + if candidateLimit > 500 { + candidateLimit = 500 + } + args := append(scoreArgs, conditionArgs...) + args = append(args, candidateLimit) + + // The limited CTE carries only article IDs and scores. Full cached bodies are + // loaded for at most candidateLimit rows in the outer query, rather than + // becoming part of the temporary sort payload. + query := ` + WITH ranked_candidates AS MATERIALIZED ( + SELECT a.id AS article_id, + (` + preliminaryScore + `) AS candidate_score, + a.published_at AS candidate_time + FROM articles a + LEFT JOIN article_contents c ON a.id = c.article_id + WHERE a.is_hidden = 0 AND (` + strings.Join(conditions, " OR ") + `) + ORDER BY candidate_score DESC, candidate_time DESC, a.id DESC + LIMIT ? + ) + SELECT a.id, a.feed_id, a.title, a.url, a.image_url, a.audio_url, a.video_url, + a.published_at, a.is_read, a.is_favorite, a.is_hidden, + a.is_read_later, a.translated_title, a.summary, a.original_summary, a.freshrss_item_id, + f.title, a.author, COALESCE(c.content, '') + FROM ranked_candidates ranked + JOIN articles a ON a.id = ranked.article_id + JOIN feeds f ON a.feed_id = f.id + LEFT JOIN article_contents c ON a.id = c.article_id + ORDER BY ranked.candidate_score DESC, ranked.candidate_time DESC, a.id DESC` - rows, err := db.Query(query) + rows, err := db.Query(query, args...) if err != nil { - return nil, fmt.Errorf("query execution failed: %w", err) + return nil, fmt.Errorf("execute parameterized AI search: %w", err) } defer rows.Close() - var articles []models.Article + candidates := make([]aiSearchCandidate, 0) for rows.Next() { - var a models.Article - var imageURL, audioURL, videoURL, translatedTitle, summary, freshrssItemID, author sql.NullString - var publishedAt sql.NullTime - if err := rows.Scan(&a.ID, &a.FeedID, &a.Title, &a.URL, &imageURL, &audioURL, &videoURL, &publishedAt, &a.IsRead, &a.IsFavorite, &a.IsHidden, &a.IsReadLater, &translatedTitle, &summary, &freshrssItemID, &a.FeedTitle, &author); err != nil { - log.Println("Error scanning article in AI search:", err) + candidate, scanErr := scanAISearchCandidate(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan AI search article: %w", scanErr) + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate AI search articles: %w", err) + } + + results := make([]AISearchResult, 0, len(candidates)) + for _, candidate := range candidates { + result := rankAISearchCandidate(candidate, search) + if result.RelevanceScore > 0 { + results = append(results, result) + } + } + sort.SliceStable(results, func(i, j int) bool { + if results[i].RelevanceScore != results[j].RelevanceScore { + return results[i].RelevanceScore > results[j].RelevanceScore + } + return results[i].Article.PublishedAt.After(results[j].Article.PublishedAt) + }) + if len(results) > search.Limit { + results = results[:search.Limit] + } + return results, nil +} + +func scanAISearchCandidate(rows *sql.Rows) (aiSearchCandidate, error) { + var candidate aiSearchCandidate + a := &candidate.article + var imageURL, audioURL, videoURL, translatedTitle, summary, originalSummary, freshrssItemID, author sql.NullString + var publishedAt sql.NullTime + if err := rows.Scan( + &a.ID, &a.FeedID, &a.Title, &a.URL, &imageURL, &audioURL, &videoURL, + &publishedAt, &a.IsRead, &a.IsFavorite, &a.IsHidden, + &a.IsReadLater, &translatedTitle, &summary, &originalSummary, &freshrssItemID, + &a.FeedTitle, &author, &candidate.content, + ); err != nil { + return candidate, err + } + a.ImageURL = imageURL.String + a.AudioURL = audioURL.String + a.VideoURL = videoURL.String + a.TranslatedTitle = translatedTitle.String + a.Summary = summary.String + a.OriginalSummary = originalSummary.String + candidate.summaryText = strings.TrimSpace(summary.String + " " + originalSummary.String) + a.FreshRSSItemID = freshrssItemID.String + a.Author = author.String + if publishedAt.Valid { + a.PublishedAt = publishedAt.Time + } + return candidate, nil +} + +func rankAISearchCandidate(candidate aiSearchCandidate, search AISearchQuery) AISearchResult { + title := cleanSearchText(candidate.article.Title) + summary := cleanSearchText(candidate.summaryText) + content := cleanSearchText(candidate.content) + fields := map[string]string{"title": title, "summary": summary, "content": content} + matchedTerms := make([]string, 0) + matchedFields := make([]string, 0, 3) + fieldSeen := make(map[string]bool) + score := 0.0 + + apply := func(term string, wildcard bool, weights map[string]float64) bool { + term = strings.TrimSpace(term) + if normalizeSearchNeedle(term) == "" { + return false + } + matched := false + for field, value := range fields { + if searchTermMatches(value, term, wildcard) { + score += weights[field] + matched = true + if !fieldSeen[field] { + fieldSeen[field] = true + matchedFields = append(matchedFields, field) + } + } + } + if matched && !containsFold(matchedTerms, term) { + matchedTerms = append(matchedTerms, term) + } + return matched + } + + apply(search.Original, false, map[string]float64{"title": 36, "summary": 20, "content": 10}) + required := uniqueSearchTerms(search.Required, 8) + requiredMatches := 0 + for _, term := range required { + if apply(term, false, map[string]float64{"title": 14, "summary": 8, "content": 4}) { + requiredMatches++ + } + } + if len(required) > 0 { + score += 10 * float64(requiredMatches) / float64(len(required)) + } + for _, term := range uniqueSearchTerms(search.Patterns, 4) { + apply(term, true, map[string]float64{"title": 18, "summary": 10, "content": 5}) + } + for _, term := range uniqueSearchTerms(search.Optional, 12) { + apply(term, false, map[string]float64{"title": 4, "summary": 2, "content": 1}) + } + + fieldOrder := map[string]int{"title": 0, "summary": 1, "content": 2} + sort.SliceStable(matchedFields, func(i, j int) bool { + return fieldOrder[matchedFields[i]] < fieldOrder[matchedFields[j]] + }) + excerptSource := summary + if excerptSource == "" || !containsAnyFold(excerptSource, matchedTerms) { + excerptSource = content + } + if excerptSource == "" { + excerptSource = title + } + + return AISearchResult{ + Article: candidate.article, + RelevanceScore: score, + MatchedTerms: matchedTerms, + MatchedFields: matchedFields, + Excerpt: makeSearchExcerpt(excerptSource, matchedTerms, 220), + } +} + +func uniqueSearchTerms(terms []string, max int) []string { + result := make([]string, 0, len(terms)) + for _, term := range terms { + term = strings.TrimSpace(term) + if term == "" || len([]rune(term)) > 80 || containsFold(result, term) { continue } - a.ImageURL = imageURL.String - a.AudioURL = audioURL.String - a.VideoURL = videoURL.String - if publishedAt.Valid { - a.PublishedAt = publishedAt.Time - } else { - a.PublishedAt = time.Time{} + result = append(result, term) + if len(result) >= max { + break } - a.TranslatedTitle = translatedTitle.String - a.Summary = summary.String - a.FreshRSSItemID = freshrssItemID.String - a.Author = author.String - articles = append(articles, a) } + return result +} - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("row iteration failed: %w", err) +func validSearchPatterns(patterns []string, max int) []string { + result := make([]string, 0, len(patterns)) + for _, pattern := range uniqueSearchTerms(patterns, max) { + // A pattern containing only '%' (or whitespace between wildcards) is + // equivalent to an unbounded table scan and offers no relevance evidence. + if normalizeSearchNeedle(pattern) == "" { + continue + } + result = append(result, pattern) } + return result +} - return articles, nil +func containsFold(values []string, target string) bool { + for _, value := range values { + if strings.EqualFold(value, target) { + return true + } + } + return false } -// SearchArticlesWithSQL executes a complete SQL query for AI search with content and relevance scoring. -// The query should include all necessary SELECT fields and be pre-validated. -func (db *DB) SearchArticlesWithSQL(query string) ([]models.Article, error) { - db.WaitForReady() +func normalizeSearchNeedle(term string) string { + term = strings.ToLower(strings.ReplaceAll(term, "%", " ")) + return strings.Join(strings.Fields(term), " ") +} - if query == "" { - return nil, fmt.Errorf("empty query") +func containsAnyFold(text string, terms []string) bool { + lower := strings.ToLower(text) + for _, term := range terms { + if needle := normalizeSearchNeedle(term); needle != "" && strings.Contains(lower, needle) { + return true + } } + return false +} - rows, err := db.Query(query) - if err != nil { - return nil, fmt.Errorf("query execution failed: %w", err) +func searchLikePattern(term string, allowWildcard bool) string { + term = strings.ToLower(strings.TrimSpace(term)) + var builder strings.Builder + for _, char := range term { + switch char { + case '\\': + builder.WriteString(`\\`) + case '_': + builder.WriteString(`\_`) + case '%': + if allowWildcard { + builder.WriteRune('%') + } else { + builder.WriteString(`\%`) + } + default: + builder.WriteRune(char) + } } - defer rows.Close() + return "%" + builder.String() + "%" +} - var articles []models.Article - for rows.Next() { - var a models.Article - var imageURL, audioURL, videoURL, translatedTitle, summary, freshrssItemID, author sql.NullString - var publishedAt sql.NullTime - var relevanceScore float64 - if err := rows.Scan(&a.ID, &a.FeedID, &a.Title, &a.URL, &imageURL, &audioURL, &videoURL, &publishedAt, &a.IsRead, &a.IsFavorite, &a.IsHidden, &a.IsReadLater, &translatedTitle, &summary, &freshrssItemID, &a.FeedTitle, &author, &relevanceScore); err != nil { - log.Println("Error scanning article in AI search with SQL:", err) +func searchTermMatches(text, term string, wildcard bool) bool { + lower := strings.ToLower(text) + if !wildcard || !strings.Contains(term, "%") { + return strings.Contains(lower, strings.ToLower(strings.TrimSpace(term))) + } + position := 0 + for _, part := range strings.Split(strings.ToLower(term), "%") { + part = strings.TrimSpace(part) + if part == "" { continue } - a.ImageURL = imageURL.String - a.AudioURL = audioURL.String - a.VideoURL = videoURL.String - if publishedAt.Valid { - a.PublishedAt = publishedAt.Time - } else { - a.PublishedAt = time.Time{} + index := strings.Index(lower[position:], part) + if index < 0 { + return false } - a.TranslatedTitle = translatedTitle.String - a.Summary = summary.String - a.FreshRSSItemID = freshrssItemID.String - a.Author = author.String - articles = append(articles, a) + position += index + len(part) } + return true +} - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("row iteration failed: %w", err) +func cleanSearchText(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + root, err := html.Parse(strings.NewReader(raw)) + if err != nil { + return strings.Join(strings.Fields(stdhtml.UnescapeString(raw)), " ") + } + var builder strings.Builder + var walk func(*html.Node, bool) + walk = func(node *html.Node, skip bool) { + if node.Type == html.ElementNode { + switch strings.ToLower(node.Data) { + case "script", "style", "noscript", "template": + skip = true + case "p", "div", "br", "li", "section", "article", "h1", "h2", "h3": + builder.WriteByte(' ') + } + } + if node.Type == html.TextNode && !skip { + builder.WriteString(node.Data) + builder.WriteByte(' ') + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child, skip) + } } + walk(root, false) + return strings.Join(strings.Fields(stdhtml.UnescapeString(builder.String())), " ") +} - return articles, nil +func makeSearchExcerpt(text string, terms []string, maxRunes int) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + runes := []rune(text) + if len(runes) <= maxRunes { + return text + } + lower := strings.ToLower(text) + matchByte := -1 + for _, term := range terms { + if needle := normalizeSearchNeedle(term); needle != "" { + if index := strings.Index(lower, needle); index >= 0 && (matchByte < 0 || index < matchByte) { + matchByte = index + } + } + } + matchRune := 0 + if matchByte > 0 { + matchRune = len([]rune(text[:matchByte])) + } + start := matchRune - maxRunes/3 + if start < 0 { + start = 0 + } + end := start + maxRunes + if end > len(runes) { + end = len(runes) + start = end - maxRunes + } + for start > 0 && !unicode.IsSpace(runes[start]) { + start-- + } + prefix, suffix := "", "" + if start > 0 { + prefix = "…" + } + if end < len(runes) { + suffix = "…" + } + return prefix + strings.TrimSpace(string(runes[start:end])) + suffix } diff --git a/internal/database/sqlite_test.go b/internal/database/sqlite_test.go index cb961fff..e616506e 100644 --- a/internal/database/sqlite_test.go +++ b/internal/database/sqlite_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -477,3 +478,133 @@ func TestCleanupUnimportantArticles(t *testing.T) { t.Fatalf("Expected idempotent cleanup to delete 0 articles, deleted %d", secondCount) } } + +func TestSearchArticlesWithTermsRanksAndExplainsMatches(t *testing.T) { + db, err := NewDB(":memory:") + if err != nil { + t.Fatalf("NewDB error: %v", err) + } + defer db.Close() + if err := db.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + result, err := db.Exec(`INSERT INTO feeds (url, title) VALUES (?, ?)`, "https://example.com/feed", "Example") + if err != nil { + t.Fatalf("insert feed: %v", err) + } + feedID, _ := result.LastInsertId() + insertArticle := func(title, originalSummary string, hidden bool) int64 { + t.Helper() + row, insertErr := db.Exec(` + INSERT INTO articles (feed_id, title, url, published_at, original_summary, is_hidden) + VALUES (?, ?, ?, ?, ?, ?) + `, feedID, title, "https://example.com/"+title, time.Now(), originalSummary, hidden) + if insertErr != nil { + t.Fatalf("insert article %q: %v", title, insertErr) + } + id, _ := row.LastInsertId() + return id + } + + titleID := insertArticle("AI safety engineering guide", "Practical controls", false) + summaryID := insertArticle("Engineering notes", "A guide to AI safety evaluations", false) + contentID := insertArticle("Research digest", "Weekly research", false) + hiddenID := insertArticle("AI safety hidden article", "must stay hidden", true) + if err := db.SetArticleContent(contentID, `

Hands-on AI safety testing.

`); err != nil { + t.Fatalf("set content: %v", err) + } + + results, err := db.SearchArticlesWithTerms(AISearchQuery{ + Original: "AI safety", + Required: []string{"AI", "safety"}, + Optional: []string{"engineering", "testing"}, + Patterns: []string{"AI%safety"}, + Limit: 10, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 visible results, got %d: %+v", len(results), results) + } + if results[0].Article.ID != titleID { + t.Fatalf("expected title phrase match first, got article %d", results[0].Article.ID) + } + seen := map[int64]AISearchResult{} + for _, searchResult := range results { + seen[searchResult.Article.ID] = searchResult + if searchResult.Article.ID == hiddenID { + t.Fatal("hidden article must not be returned") + } + if searchResult.RelevanceScore <= 0 || len(searchResult.MatchedTerms) == 0 || len(searchResult.MatchedFields) == 0 { + t.Fatalf("missing relevance explanation: %+v", searchResult) + } + } + if _, ok := seen[summaryID]; !ok { + t.Fatal("expected original RSS summary match") + } + contentResult, ok := seen[contentID] + if !ok { + t.Fatal("expected article content match") + } + if strings.Contains(contentResult.Excerpt, "<") || strings.Contains(contentResult.Excerpt, "secret()") { + t.Fatalf("excerpt was not sanitized: %q", contentResult.Excerpt) + } + + injectionResults, err := db.SearchArticlesWithTerms(AISearchQuery{ + Original: `' OR 1=1 --`, + Required: []string{`%' OR 1=1 --`}, + Limit: 10, + }) + if err != nil { + t.Fatalf("parameterized search rejected input unexpectedly: %v", err) + } + if len(injectionResults) != 0 { + t.Fatalf("SQL-like input must not broaden results: %+v", injectionResults) + } + + // A broad expansion can match more rows than the bounded candidate set. + // The exact original phrase must be prioritized before LIMIT, even when it + // is older and inserted after all broad matches. + for i := 0; i < 230; i++ { + if _, err := db.Exec(` + INSERT INTO articles (feed_id, title, url, published_at, original_summary) + VALUES (?, ?, ?, ?, ?) + `, feedID, fmt.Sprintf("Broad result %03d", i), fmt.Sprintf("https://example.com/broad/%d", i), time.Now(), "common expansion"); err != nil { + t.Fatalf("insert broad search candidate %d: %v", i, err) + } + } + exactResult, err := db.Exec(` + INSERT INTO articles (feed_id, title, url, published_at, original_summary) + VALUES (?, ?, ?, ?, ?) + `, feedID, "rare exact phrase", "https://example.com/exact", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), "common expansion") + if err != nil { + t.Fatalf("insert exact search candidate: %v", err) + } + exactID, _ := exactResult.LastInsertId() + + boundedResults, err := db.SearchArticlesWithTerms(AISearchQuery{ + Original: "rare exact phrase", + Required: []string{"common"}, + Limit: 10, + }) + if err != nil { + t.Fatalf("bounded candidate search: %v", err) + } + if len(boundedResults) == 0 || boundedResults[0].Article.ID != exactID { + t.Fatalf("expected exact phrase result before bounded broad matches, got %+v", boundedResults) + } + + wildcardOnlyResults, err := db.SearchArticlesWithTerms(AISearchQuery{ + Original: "missing literal phrase", + Patterns: []string{"%", " % % "}, + Limit: 10, + }) + if err != nil { + t.Fatalf("wildcard-only pattern search: %v", err) + } + if len(wildcardOnlyResults) != 0 { + t.Fatalf("wildcard-only patterns must not broaden results: %+v", wildcardOnlyResults) + } +} diff --git a/internal/handlers/ai/ai_search.go b/internal/handlers/ai/ai_search.go index e453df5b..1601f6bb 100644 --- a/internal/handlers/ai/ai_search.go +++ b/internal/handlers/ai/ai_search.go @@ -5,12 +5,14 @@ import ( "fmt" "log" "net/http" + "net/url" "regexp" "strings" "time" "MrRSS/internal/ai" "MrRSS/internal/config" + "MrRSS/internal/database" "MrRSS/internal/handlers/core" "MrRSS/internal/handlers/response" ) @@ -22,11 +24,12 @@ type AISearchRequest struct { // AISearchResponse represents the response from AI search type AISearchResponse struct { - Success bool `json:"success"` - Articles []map[string]any `json:"articles,omitempty"` - SearchTerms string `json:"search_terms,omitempty"` - Error string `json:"error,omitempty"` - TotalCount int `json:"total_count"` + Success bool `json:"success"` + Articles []map[string]any `json:"articles,omitempty"` + SearchTerms string `json:"search_terms,omitempty"` + ExpandedTerms *SearchTerms `json:"expanded_terms,omitempty"` + Error string `json:"error,omitempty"` + TotalCount int `json:"total_count"` } // extractSearchTerms extracts the search terms from AI response @@ -78,9 +81,57 @@ func parseSearchTermsAdvanced(response string) (*SearchTerms, error) { return nil, fmt.Errorf("no search terms extracted") } + terms.Required = normalizeTerms(terms.Required, 8) + terms.Optional = normalizeTerms(terms.Optional, 12) + terms.Patterns = normalizeSearchPatterns(terms.Patterns, 4) return &terms, nil } +func normalizeSearchPatterns(values []string, max int) []string { + result := make([]string, 0, len(values)) + for _, value := range normalizeTerms(values, max) { + plain := strings.Join(strings.Fields(strings.ReplaceAll(value, "%", " ")), " ") + if plain == "" { + continue + } + result = append(result, value) + } + return result +} + +func endpointForLog(rawEndpoint string) string { + parsed, err := url.Parse(strings.TrimSpace(rawEndpoint)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func normalizeTerms(values []string, max int) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}) + for _, value := range values { + value = strings.TrimSpace(value) + key := strings.ToLower(value) + if value == "" || len([]rune(value)) > 80 { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, value) + if len(result) >= max { + break + } + } + return result +} + // buildAISearchPrompt creates a concise system prompt for keyword expansion func buildAISearchPrompt() string { return `Expand search query into structured search terms. Output ONLY a JSON object: @@ -104,95 +155,6 @@ Input: "Python web框架" Output: {"required":["Python","web框架","web framework"],"optional":["Django","Flask","FastAPI","后端","backend"],"patterns":["Python%web","Python%框架"]}` } -// buildSearchSQL builds the SQL query from search terms with relevance scoring -func buildSearchSQL(terms *SearchTerms, limit int) string { - if terms == nil || (len(terms.Required) == 0 && len(terms.Patterns) == 0) { - return "" - } - - // Build required conditions (must match at least one) - var requiredConditions []string - for _, term := range terms.Required { - escapedTerm := strings.ReplaceAll(term, "'", "''") - condition := fmt.Sprintf( - "(a.title LIKE '%%%s%%' OR c.content LIKE '%%%s%%' OR a.summary LIKE '%%%s%%')", - escapedTerm, escapedTerm, escapedTerm, - ) - requiredConditions = append(requiredConditions, condition) - } - - // Build pattern conditions - for _, pattern := range terms.Patterns { - escapedPattern := strings.ReplaceAll(pattern, "'", "''") - condition := fmt.Sprintf( - "(a.title LIKE '%%%s%%' OR c.content LIKE '%%%s%%' OR a.summary LIKE '%%%s%%')", - escapedPattern, escapedPattern, escapedPattern, - ) - requiredConditions = append(requiredConditions, condition) - } - - // Build relevance score with weighted scoring - var scoreTerms []string - - // Required terms get higher base weight - for _, term := range terms.Required { - escapedTerm := strings.ReplaceAll(term, "'", "''") - scoreTerm := fmt.Sprintf( - "(CASE WHEN a.title LIKE '%%%s%%' THEN 5 ELSE 0 END + "+ - "CASE WHEN c.content LIKE '%%%s%%' THEN 2 ELSE 0 END + "+ - "CASE WHEN a.summary LIKE '%%%s%%' THEN 3 ELSE 0 END)", - escapedTerm, escapedTerm, escapedTerm, - ) - scoreTerms = append(scoreTerms, scoreTerm) - } - - // Patterns get highest weight (exact phrase match) - for _, pattern := range terms.Patterns { - escapedPattern := strings.ReplaceAll(pattern, "'", "''") - scoreTerm := fmt.Sprintf( - "(CASE WHEN a.title LIKE '%%%s%%' THEN 8 ELSE 0 END + "+ - "CASE WHEN c.content LIKE '%%%s%%' THEN 4 ELSE 0 END + "+ - "CASE WHEN a.summary LIKE '%%%s%%' THEN 5 ELSE 0 END)", - escapedPattern, escapedPattern, escapedPattern, - ) - scoreTerms = append(scoreTerms, scoreTerm) - } - - // Optional terms get lower weight - for _, term := range terms.Optional { - escapedTerm := strings.ReplaceAll(term, "'", "''") - scoreTerm := fmt.Sprintf( - "(CASE WHEN a.title LIKE '%%%s%%' THEN 2 ELSE 0 END + "+ - "CASE WHEN c.content LIKE '%%%s%%' THEN 1 ELSE 0 END + "+ - "CASE WHEN a.summary LIKE '%%%s%%' THEN 1 ELSE 0 END)", - escapedTerm, escapedTerm, escapedTerm, - ) - scoreTerms = append(scoreTerms, scoreTerm) - } - - relevanceScore := "0" - if len(scoreTerms) > 0 { - relevanceScore = strings.Join(scoreTerms, " + ") - } - - // Build full query with LEFT JOIN to article_contents for content search - whereClause := strings.Join(requiredConditions, " OR ") - query := fmt.Sprintf(` - SELECT a.id, a.feed_id, a.title, a.url, a.image_url, a.audio_url, a.video_url, - a.published_at, a.is_read, a.is_favorite, a.is_hidden, a.is_read_later, - a.translated_title, a.summary, a.freshrss_item_id, f.title AS feed_title, a.author, - (%s) AS relevance_score - FROM articles a - JOIN feeds f ON a.feed_id = f.id - LEFT JOIN article_contents c ON a.id = c.article_id - WHERE a.is_hidden = 0 AND (%s) - ORDER BY relevance_score DESC, a.published_at DESC - LIMIT %d - `, relevanceScore, whereClause, limit) - - return query -} - // HandleAISearch handles POST /api/ai/search for AI-powered article search // @Summary AI-powered article search // @Description Use AI to expand keywords and search articles with relevance ranking @@ -228,7 +190,8 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { return } - log.Printf("[AI Search] User query: %s", req.Query) + query := strings.TrimSpace(req.Query) + log.Printf("[AI Search] Starting search (query length: %d)", len([]rune(query))) // Get AI settings - try ProfileProvider first var apiKey, endpoint, model string @@ -238,7 +201,7 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { apiKey = cfg.APIKey endpoint = cfg.Endpoint model = cfg.Model - log.Printf("[AI Search] Using AI profile for search (endpoint: %s, model: %s)", endpoint, model) + log.Printf("[AI Search] Using AI profile for search (endpoint: %s, model: %s)", endpointForLog(endpoint), model) } } @@ -256,7 +219,7 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { if model == "" { model = defaults.AIModel } - log.Printf("[AI Search] Using global AI settings for search (endpoint: %s, model: %s)", endpoint, model) + log.Printf("[AI Search] Using global AI settings for search (endpoint: %s, model: %s)", endpointForLog(endpoint), model) } // Validate AI configuration @@ -298,8 +261,6 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { return } - log.Printf("[AI Search] AI response: %s", aiResponse) - // Parse search terms from AI response searchTerms, err := parseSearchTermsAdvanced(aiResponse) if err != nil { @@ -315,14 +276,20 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { allTerms = append(allTerms, searchTerms.Required...) allTerms = append(allTerms, searchTerms.Optional...) allTerms = append(allTerms, searchTerms.Patterns...) - log.Printf("[AI Search] Required: %v, Optional: %v, Patterns: %v", searchTerms.Required, searchTerms.Optional, searchTerms.Patterns) - - // Build and execute search query - searchSQL := buildSearchSQL(searchTerms, 100) - log.Printf("[AI Search] SQL query:\n%s", searchSQL) - - // Execute search - articles, err := h.DB.SearchArticlesWithSQL(searchSQL) + log.Printf( + "[AI Search] Expanded terms (required=%d optional=%d patterns=%d)", + len(searchTerms.Required), len(searchTerms.Optional), len(searchTerms.Patterns), + ) + + // Execute a parameterized candidate query and deterministic local ranking. + // The AI response is never treated as SQL. + results, err := h.DB.SearchArticlesWithTerms(database.AISearchQuery{ + Original: query, + Required: searchTerms.Required, + Optional: searchTerms.Optional, + Patterns: searchTerms.Patterns, + Limit: 100, + }) if err != nil { log.Printf("[AI Search] Query error: %v", err) response.JSON(w, AISearchResponse{ @@ -333,11 +300,12 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { return } - log.Printf("[AI Search] Found %d articles", len(articles)) + log.Printf("[AI Search] Found %d articles", len(results)) // Convert articles to response format - articleMaps := make([]map[string]any, len(articles)) - for i, article := range articles { + articleMaps := make([]map[string]any, len(results)) + for i, result := range results { + article := result.Article articleMaps[i] = map[string]any{ "id": article.ID, "feed_id": article.FeedID, @@ -355,13 +323,19 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) { "author": article.Author, "translated_title": article.TranslatedTitle, "summary": article.Summary, + "original_summary": article.OriginalSummary, + "relevance_score": result.RelevanceScore, + "matched_terms": result.MatchedTerms, + "matched_fields": result.MatchedFields, + "excerpt": result.Excerpt, } } response.JSON(w, AISearchResponse{ - Success: true, - Articles: articleMaps, - SearchTerms: strings.Join(allTerms, ", "), - TotalCount: len(articles), + Success: true, + Articles: articleMaps, + SearchTerms: strings.Join(allTerms, ", "), + ExpandedTerms: searchTerms, + TotalCount: len(results), }) } From 15b413f5d66dbcb8f62532f1dbaffc729d0745ee Mon Sep 17 00:00:00 2001 From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:11:34 +0800 Subject: [PATCH 05/59] fix(settings): update AI usage limit display immediately --- .../cypress/e2e/settings-persistence.cy.ts | 61 ++++++++ .../modals/settings/ai/AIUsageSettings.vue | 132 +++++++++++++----- 2 files changed, 159 insertions(+), 34 deletions(-) diff --git a/frontend/cypress/e2e/settings-persistence.cy.ts b/frontend/cypress/e2e/settings-persistence.cy.ts index 638fec30..8fafebf7 100644 --- a/frontend/cypress/e2e/settings-persistence.cy.ts +++ b/frontend/cypress/e2e/settings-persistence.cy.ts @@ -487,4 +487,65 @@ describe('Settings Persistence', () => { expect(layoutMode).to.eq('normal'); }); }); + + it('updates the AI usage limit immediately and refreshes usage while visible', () => { + let usage = 120; + let savedLimit = '20000'; + let usageRequests = 0; + + cy.intercept('GET', '/api/ai/profiles', { statusCode: 200, body: [] }); + cy.intercept('GET', '/api/ai-usage', (req) => { + usageRequests += 1; + const limit = Number(savedLimit); + req.reply({ + statusCode: 200, + body: { + usage, + limit, + limit_reached: limit > 0 && usage >= limit, + }, + }); + }).as('getAIUsage'); + cy.intercept('POST', '/api/settings', (req) => { + if (req.body.ai_usage_limit !== undefined) { + savedLimit = String(req.body.ai_usage_limit); + } + req.reply({ statusCode: 200, body: req.body }); + }).as('saveAIUsageLimit'); + + cy.clock(); + cy.get('button') + .filter('[title="Settings"], [title="设置"]') + .should('exist') + .click({ force: true }); + cy.wait('@getSettings'); + cy.contains('button', /^AI$/i).click({ force: true }); + cy.wait('@getAIUsage'); + + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 20,000'); + cy.get('[data-testid="ai-usage-limit-input"]').clear().type('400'); + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 400'); + + cy.tick(500); + cy.wait('@saveAIUsageLimit').its('request.body.ai_usage_limit').should('eq', '400'); + cy.wait('@getAIUsage'); + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 400'); + + cy.then(() => { + usage = 300; + }); + cy.tick(15_000); + cy.wait('@getAIUsage'); + cy.get('[data-testid="ai-usage-status"]').should('contain.text', '300 / 400'); + + let requestsBeforeUnmount = 0; + cy.then(() => { + requestsBeforeUnmount = usageRequests; + }); + cy.contains('button', /^(General|常规)$/i).click({ force: true }); + cy.tick(30_000); + cy.then(() => { + expect(usageRequests).to.eq(requestsBeforeUnmount); + }); + }); }); diff --git a/frontend/src/components/modals/settings/ai/AIUsageSettings.vue b/frontend/src/components/modals/settings/ai/AIUsageSettings.vue index 73ad0051..cbb8d655 100644 --- a/frontend/src/components/modals/settings/ai/AIUsageSettings.vue +++ b/frontend/src/components/modals/settings/ai/AIUsageSettings.vue @@ -1,5 +1,5 @@ @@ -97,12 +159,13 @@ onMounted(() => { { onClick: resetAIUsage, }" :status-info=" - aiUsage.limit > 0 + currentUsageLimit > 0 ? { label: t('common.text.progress'), - time: getUsagePercentage().toFixed(2) + '%', + time: usagePercentage.toFixed(2) + '%', } : undefined " @@ -128,6 +191,7 @@ onMounted(() => { :description="t('setting.ai.setUsageLimitDesc')" > Date: Mon, 24 Aug 2026 19:13:24 +0800 Subject: [PATCH 06/59] fix(database): retry locked article batch writes --- internal/database/article_db.go | 90 ++++++++++++++-- internal/database/article_db_test.go | 120 ++++++++++++++++++++++ internal/feed/intelligent_refresh_test.go | 47 ++++++--- 3 files changed, 234 insertions(+), 23 deletions(-) diff --git a/internal/database/article_db.go b/internal/database/article_db.go index 9e290a6e..92d87c87 100644 --- a/internal/database/article_db.go +++ b/internal/database/article_db.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "errors" "fmt" "log" "strings" @@ -10,8 +11,17 @@ import ( "MrRSS/internal/models" "MrRSS/internal/utils/urlutil" + + "modernc.org/sqlite" ) +const saveArticlesMaxAttempts = 3 + +var saveArticlesRetryDelays = [...]time.Duration{ + 100 * time.Millisecond, + 250 * time.Millisecond, +} + // SaveArticle saves a single article to the database. func (db *DB) SaveArticle(article *models.Article) error { db.WaitForReady() @@ -45,9 +55,43 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro } } + for attempt := 1; attempt <= saveArticlesMaxAttempts; attempt++ { + err := db.saveArticlesOnce(ctx, articles) + if err == nil { + return nil + } + if !isRetryableSQLiteWriteError(err) || attempt == saveArticlesMaxAttempts { + return fmt.Errorf("save %d articles after %d attempt(s): %w", len(articles), attempt, err) + } + + delay := saveArticlesRetryDelays[attempt-1] + log.Printf( + "SaveArticles write contention on attempt %d/%d; retrying in %s: %v", + attempt, + saveArticlesMaxAttempts, + delay, + err, + ) + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return fmt.Errorf("save %d articles while waiting to retry: %w", len(articles), ctx.Err()) + case <-timer.C: + } + } + + return fmt.Errorf("save %d articles: retry attempts exhausted", len(articles)) +} + +// saveArticlesOnce performs one all-or-nothing transaction. Any error aborts +// the batch so callers never observe a successful partial refresh. +func (db *DB) saveArticlesOnce(ctx context.Context, articles []*models.Article) error { tx, err := db.BeginTx(ctx, nil) if err != nil { - return err + return fmt.Errorf("begin transaction: %w", err) } defer tx.Rollback() @@ -77,15 +121,15 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro author = excluded.author `) if err != nil { - return err + return fmt.Errorf("prepare article upsert: %w", err) } defer stmt.Close() - for _, article := range articles { + for index, article := range articles { // Check context before each insert select { case <-ctx.Done(): - return ctx.Err() + return fmt.Errorf("article %d/%d: %w", index+1, len(articles), ctx.Err()) default: } @@ -128,6 +172,14 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro article.OriginalSummary != existingOriginalSummary || article.Author != existingAuthor updatePublishedAt = article.HasValidPublishedTime || articleChanged + } else if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf( + "article %d/%d (feed %d): load existing state: %w", + index+1, + len(articles), + article.FeedID, + err, + ) } updateTime := 0 @@ -136,12 +188,36 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro } _, err = stmt.ExecContext(ctx, article.FeedID, article.Title, article.URL, article.ImageURL, article.AudioURL, article.VideoURL, article.PublishedAt, article.TranslatedTitle, isRead, isFavorite, isHidden, isReadLater, article.Summary, article.OriginalSummary, uniqueID, article.Author, updateTime) if err != nil { - log.Println("Error saving article in batch:", err) - // Continue even if one fails + return fmt.Errorf( + "article %d/%d (feed %d): upsert: %w", + index+1, + len(articles), + article.FeedID, + err, + ) } } - return tx.Commit() + if err := stmt.Close(); err != nil { + return fmt.Errorf("close article upsert statement: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +func isRetryableSQLiteWriteError(err error) bool { + var sqliteErr *sqlite.Error + if !errors.As(err, &sqliteErr) { + return false + } + + // SQLite extended result codes retain the primary result in the low byte. + // This covers SQLITE_BUSY (5), SQLITE_LOCKED (6), and variants such as + // SQLITE_BUSY_SNAPSHOT (517). + primaryCode := sqliteErr.Code() & 0xff + return primaryCode == 5 || primaryCode == 6 } // GetArticles retrieves articles with filtering, pagination, and sorting. diff --git a/internal/database/article_db_test.go b/internal/database/article_db_test.go index ac2eda5f..9d24be96 100644 --- a/internal/database/article_db_test.go +++ b/internal/database/article_db_test.go @@ -2,7 +2,11 @@ package database_test import ( "context" + "database/sql" + "errors" "fmt" + "log" + "path/filepath" "strings" "testing" "time" @@ -418,6 +422,122 @@ func TestSaveArticlesBatchContextCancel(t *testing.T) { } } +func TestSaveArticlesRollsBackEntireBatchOnPermanentError(t *testing.T) { + db := setupDBWithFeed(t) + + var feedID int64 + if err := db.QueryRow(`SELECT id FROM feeds WHERE url = ?`, "https://example.com/feed").Scan(&feedID); err != nil { + t.Fatalf("scan feed id: %v", err) + } + + articles := []*models.Article{ + { + FeedID: feedID, + Title: "valid article must be rolled back", + URL: "https://example.com/atomic-valid", + PublishedAt: time.Now().UTC(), + HasValidPublishedTime: true, + }, + { + FeedID: feedID + 10000, + Title: "invalid feed", + URL: "https://example.com/atomic-invalid", + PublishedAt: time.Now().UTC(), + HasValidPublishedTime: true, + }, + } + + err := db.SaveArticles(context.Background(), articles) + if err == nil { + t.Fatal("expected the invalid foreign key to fail the batch") + } + if !strings.Contains(err.Error(), "article 2/2") || !strings.Contains(err.Error(), "attempt(s)") { + t.Fatalf("expected batch and attempt context, got %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM articles WHERE url IN (?, ?)`, articles[0].URL, articles[1].URL).Scan(&count); err != nil { + t.Fatalf("count batch articles: %v", err) + } + if count != 0 { + t.Fatalf("batch committed partially: got %d stored articles, want 0", count) + } +} + +func TestSaveArticlesRetriesSQLiteWriteContention(t *testing.T) { + databasePath := filepath.Join(t.TempDir(), "write-contention.db") + db, err := dbpkg.NewDB(databasePath) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if _, err := db.Exec(`PRAGMA busy_timeout = 1`); err != nil { + t.Fatalf("set short busy timeout: %v", err) + } + result, err := db.Exec(`INSERT INTO feeds (title, url, category) VALUES (?, ?, ?)`, "Locked Feed", "https://example.com/locked-feed", "news") + if err != nil { + t.Fatalf("insert feed: %v", err) + } + feedID, err := result.LastInsertId() + if err != nil { + t.Fatalf("feed id: %v", err) + } + + locker, err := dbpkg.NewDB(databasePath) + if err != nil { + t.Fatalf("NewDB locker: %v", err) + } + t.Cleanup(func() { _ = locker.Close() }) + lockTx, err := locker.BeginTx(context.Background(), nil) + if err != nil { + t.Fatalf("begin lock transaction: %v", err) + } + if _, err := lockTx.Exec(`UPDATE settings SET value = value WHERE key = 'theme'`); err != nil { + t.Fatalf("acquire write lock: %v", err) + } + + previousLogWriter := log.Writer() + var retryLog strings.Builder + log.SetOutput(&retryLog) + t.Cleanup(func() { log.SetOutput(previousLogWriter) }) + + releaseDone := make(chan error, 1) + go func() { + time.Sleep(50 * time.Millisecond) + releaseDone <- lockTx.Rollback() + }() + + article := &models.Article{ + FeedID: feedID, + Title: "saved after lock retry", + URL: "https://example.com/saved-after-lock-retry", + PublishedAt: time.Now().UTC(), + HasValidPublishedTime: true, + } + if err := db.SaveArticles(context.Background(), []*models.Article{article}); err != nil { + t.Fatalf("SaveArticles after temporary lock: %v", err) + } + if err := <-releaseDone; err != nil && !errors.Is(err, sql.ErrTxDone) { + t.Fatalf("release lock: %v", err) + } + if !strings.Contains(retryLog.String(), "retrying") { + t.Fatalf("expected a contention retry, logs: %s", retryLog.String()) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM articles WHERE url = ?`, article.URL).Scan(&count); err != nil { + t.Fatalf("count retried article: %v", err) + } + if count != 1 { + t.Fatalf("retried article count = %d, want 1", count) + } +} + func TestSaveArticlesUpdatePreservesRelatedData(t *testing.T) { db := setupDBWithFeed(t) diff --git a/internal/feed/intelligent_refresh_test.go b/internal/feed/intelligent_refresh_test.go index a7641320..4e228fec 100644 --- a/internal/feed/intelligent_refresh_test.go +++ b/internal/feed/intelligent_refresh_test.go @@ -2,6 +2,7 @@ package feed import ( "context" + "fmt" "testing" "time" @@ -9,6 +10,17 @@ import ( "MrRSS/internal/models" ) +func addIntelligentRefreshTestFeed(t *testing.T, db *database.DB) models.Feed { + t.Helper() + feed := models.Feed{Title: "Test Feed", URL: "https://example.com/test-feed", Type: "rss"} + feedID, err := db.AddFeed(&feed) + if err != nil { + t.Fatalf("AddFeed error: %v", err) + } + feed.ID = feedID + return feed +} + func TestIntelligentRefreshCalculator_NoArticles(t *testing.T) { tmpFile := t.TempDir() + "/test.db" db, err := database.NewDB(tmpFile) @@ -21,7 +33,7 @@ func TestIntelligentRefreshCalculator_NoArticles(t *testing.T) { defer db.Close() calculator := NewIntelligentRefreshCalculator(db) - feed := models.Feed{ID: 1, Title: "Test Feed"} + feed := addIntelligentRefreshTestFeed(t, db) // Test with no articles - should return default interval interval := calculator.CalculateInterval(feed) @@ -57,17 +69,18 @@ func TestIntelligentRefreshCalculator_Bounds(t *testing.T) { defer db.Close() calculator := NewIntelligentRefreshCalculator(db) - feed := models.Feed{ID: 1, Title: "Test Feed"} + feed := addIntelligentRefreshTestFeed(t, db) // Create articles with very high frequency (every 10 seconds) now := time.Now() articles := make([]*models.Article, 10) for i := 0; i < 10; i++ { articles[i] = &models.Article{ - FeedID: 1, - Title: "Article", - URL: "http://example.com/article", - PublishedAt: now.Add(time.Duration(-i) * 10 * time.Second), + FeedID: feed.ID, + Title: fmt.Sprintf("Article %d", i), + URL: fmt.Sprintf("http://example.com/article/%d", i), + PublishedAt: now.Add(time.Duration(-i) * 10 * time.Second), + HasValidPublishedTime: true, } } @@ -100,17 +113,18 @@ func TestIntelligentRefreshCalculator_LowFrequency(t *testing.T) { defer db.Close() calculator := NewIntelligentRefreshCalculator(db) - feed := models.Feed{ID: 1, Title: "Test Feed"} + feed := addIntelligentRefreshTestFeed(t, db) // Create articles with very low frequency (every 48 hours) now := time.Now() articles := make([]*models.Article, 10) for i := 0; i < 10; i++ { articles[i] = &models.Article{ - FeedID: 1, - Title: "Article", - URL: "http://example.com/article", - PublishedAt: now.Add(time.Duration(-i) * 48 * time.Hour), + FeedID: feed.ID, + Title: fmt.Sprintf("Article %d", i), + URL: fmt.Sprintf("http://example.com/article/%d", i), + PublishedAt: now.Add(time.Duration(-i) * 48 * time.Hour), + HasValidPublishedTime: true, } } @@ -156,17 +170,18 @@ func TestIntelligentRefreshCalculator_WithArticles(t *testing.T) { defer db.Close() calculator := NewIntelligentRefreshCalculator(db) - feed := models.Feed{ID: 1, Title: "Test Feed"} + feed := addIntelligentRefreshTestFeed(t, db) // Create articles published every 2 hours now := time.Now() articles := make([]*models.Article, 20) for i := 0; i < 20; i++ { articles[i] = &models.Article{ - FeedID: 1, - Title: "Article", - URL: "http://example.com/article", - PublishedAt: now.Add(time.Duration(-i*2) * time.Hour), + FeedID: feed.ID, + Title: fmt.Sprintf("Article %d", i), + URL: fmt.Sprintf("http://example.com/article/%d", i), + PublishedAt: now.Add(time.Duration(-i*2) * time.Hour), + HasValidPublishedTime: true, } } From 80340b684c033f4ed2a5919b047701e2c797f323 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 20:05:50 +0800 Subject: [PATCH 07/59] feat(macos): add native SwiftUI client --- .github/workflows/release.yml | 41 ++ .github/workflows/test.yml | 16 + .gitignore | 22 + frontend-swift/Package.swift | 23 + frontend-swift/README.md | 61 ++ frontend-swift/Sources/Models/Models.swift | 242 ++++++++ .../Sources/Models/SettingsCatalog.swift | 173 ++++++ frontend-swift/Sources/MrRSSApp.swift | 31 ++ .../Sources/Services/APIService.swift | 396 +++++++++++++ .../Sources/Services/BackendManager.swift | 52 ++ .../Sources/ViewModels/AppViewModel.swift | 527 ++++++++++++++++++ .../Sources/Views/AddFeedView.swift | 63 +++ .../Sources/Views/ArticleDetailView.swift | 339 +++++++++++ .../Sources/Views/ArticleListView.swift | 91 +++ .../Sources/Views/ArticleRowView.swift | 115 ++++ .../Sources/Views/ContentView.swift | 50 ++ .../Sources/Views/RulesSettingsView.swift | 286 ++++++++++ .../Sources/Views/ServerSettingsView.swift | 39 ++ .../Sources/Views/SettingsRootView.swift | 212 +++++++ .../Sources/Views/SidebarView.swift | 159 ++++++ frontend-swift/Sources/Views/WebView.swift | 222 ++++++++ frontend-swift/Tests/APIServiceTests.swift | 178 ++++++ frontend-swift/Tests/AppViewModelTests.swift | 178 ++++++ frontend-swift/Tests/HTMLDocumentTests.swift | 24 + frontend-swift/Tests/ModelsTests.swift | 41 ++ frontend-swift/Tests/WebViewLayoutTests.swift | 145 +++++ frontend-swift/build-app.sh | 90 +++ frontend-swift/packaging/Info.plist | 39 ++ frontend-swift/run.sh | 49 ++ 29 files changed, 3904 insertions(+) create mode 100644 frontend-swift/Package.swift create mode 100644 frontend-swift/README.md create mode 100644 frontend-swift/Sources/Models/Models.swift create mode 100644 frontend-swift/Sources/Models/SettingsCatalog.swift create mode 100644 frontend-swift/Sources/MrRSSApp.swift create mode 100644 frontend-swift/Sources/Services/APIService.swift create mode 100644 frontend-swift/Sources/Services/BackendManager.swift create mode 100644 frontend-swift/Sources/ViewModels/AppViewModel.swift create mode 100644 frontend-swift/Sources/Views/AddFeedView.swift create mode 100644 frontend-swift/Sources/Views/ArticleDetailView.swift create mode 100644 frontend-swift/Sources/Views/ArticleListView.swift create mode 100644 frontend-swift/Sources/Views/ArticleRowView.swift create mode 100644 frontend-swift/Sources/Views/ContentView.swift create mode 100644 frontend-swift/Sources/Views/RulesSettingsView.swift create mode 100644 frontend-swift/Sources/Views/ServerSettingsView.swift create mode 100644 frontend-swift/Sources/Views/SettingsRootView.swift create mode 100644 frontend-swift/Sources/Views/SidebarView.swift create mode 100644 frontend-swift/Sources/Views/WebView.swift create mode 100644 frontend-swift/Tests/APIServiceTests.swift create mode 100644 frontend-swift/Tests/AppViewModelTests.swift create mode 100644 frontend-swift/Tests/HTMLDocumentTests.swift create mode 100644 frontend-swift/Tests/ModelsTests.swift create mode 100644 frontend-swift/Tests/WebViewLayoutTests.swift create mode 100755 frontend-swift/build-app.sh create mode 100644 frontend-swift/packaging/Info.plist create mode 100755 frontend-swift/run.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52d7e527..6b5b3414 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -605,3 +605,44 @@ jobs: build/bin/MrRSS-${{ needs.create-release.outputs.version }}-* token: ${{ secrets.GITHUB_TOKEN }} fail_on_unmatched_files: false + + build-swiftui-macos: + name: Build SwiftUI macOS Universal Client + needs: create-release + runs-on: macos-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.create-release.outputs.tag }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Build embedded web assets + working-directory: ./frontend + run: | + npm ci + npm run build + + - name: Build SwiftUI application bundle + run: | + chmod +x frontend-swift/build-app.sh + frontend-swift/build-app.sh "${{ needs.create-release.outputs.version }}" + + - name: Upload SwiftUI macOS release asset + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.create-release.outputs.tag }} + files: frontend-swift/dist/MrRSS-${{ needs.create-release.outputs.version }}-macos-swiftui-universal.dmg + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b51e2381..41935d22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,6 +101,22 @@ jobs: working-directory: ./frontend run: npm run build + test-swiftui-macos: + name: Test SwiftUI macOS Client + runs-on: macos-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Run Swift tests + working-directory: ./frontend-swift + run: swift test + + - name: Build release executable + working-directory: ./frontend-swift + run: swift build -c release + build-check: name: Build Check runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 0ff73339..188a7324 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,25 @@ frontend/cypress/downloads/ # Claude .claude/ .plan/ + +# Swift Frontend +# +# Packages +.swiftpm +.build + +# Xcode +# +# Any generated Xcode project or workspace +*.xcodeproj +*.xcworkspace +# Derived data (includes intermediate build results) +DerivedData/ +# Swift Package Manager checkouts (if managed by Xcode) +Checkouts/ + +# MacOS +.DS_Store + + + diff --git a/frontend-swift/Package.swift b/frontend-swift/Package.swift new file mode 100644 index 00000000..bd3b7447 --- /dev/null +++ b/frontend-swift/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "MrRSS", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "MrRSS", targets: ["MrRSS"]) + ], + targets: [ + .executableTarget( + name: "MrRSS", + path: "Sources" + ), + .testTarget( + name: "MrRSSTests", + dependencies: ["MrRSS"], + path: "Tests" + ) + ] +) diff --git a/frontend-swift/README.md b/frontend-swift/README.md new file mode 100644 index 00000000..f5ed806f --- /dev/null +++ b/frontend-swift/README.md @@ -0,0 +1,61 @@ +# MrRSS SwiftUI Frontend + +This directory contains the native macOS 14 SwiftUI frontend for MrRSS. It uses the existing Go HTTP API and does not modify the Vue/Wails frontend. + +## Run directly + +From the repository root: + +```bash +./frontend-swift/run.sh +``` + +The launcher builds and starts the Go server at `http://127.0.0.1:1234`, waits for the API to become available, and then starts the SwiftUI application. If a server is already listening on that address, the launcher reuses it. + +Requirements: + +- macOS 14 or later +- Xcode 15 or later +- Go 1.24 or later +- The existing `frontend/dist` directory, which is embedded by the server build + +## Run components separately + +Start the backend from the repository root: + +```bash +go run -tags server . -host 127.0.0.1 -port 1234 +``` + +In another terminal, start the macOS frontend: + +```bash +swift run --package-path frontend-swift MrRSS +``` + +The backend address can be changed in MrRSS Settings. It can also be set before launch: + +```bash +MRRSS_API_BASE_URL=http://127.0.0.1:8080/api swift run --package-path frontend-swift MrRSS +``` + +## Build and test + +```bash +swift build --package-path frontend-swift +swift test --package-path frontend-swift +``` + +To create the universal `.app` bundle and DMG used by GitHub Releases: + +```bash +cd frontend +npm ci +npm run build +cd ../frontend-swift +./build-app.sh 1.2.3 +``` + +The release application contains both the SwiftUI frontend and reusable Go backend, so it can launch without a separately installed server. + +The application provides native three-column navigation, feed subscription management, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. diff --git a/frontend-swift/Sources/Models/Models.swift b/frontend-swift/Sources/Models/Models.swift new file mode 100644 index 00000000..5e677ba8 --- /dev/null +++ b/frontend-swift/Sources/Models/Models.swift @@ -0,0 +1,242 @@ +import Foundation + +struct Feed: Identifiable, Codable, Hashable { + let id: Int + let url: String + let title: String + let category: String + let lastUpdated: String + let iconURL: String? + + enum CodingKeys: String, CodingKey { + case id, url, title, category + case lastUpdated = "last_updated" + case iconURL = "image_url" + } + + init( + id: Int, + url: String, + title: String, + category: String, + lastUpdated: String = "", + iconURL: String? = nil + ) { + self.id = id + self.url = url + self.title = title + self.category = category + self.lastUpdated = lastUpdated + self.iconURL = iconURL?.nilIfEmpty + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? url + category = try container.decodeIfPresent(String.self, forKey: .category) ?? "" + lastUpdated = try container.decodeIfPresent(String.self, forKey: .lastUpdated) ?? "" + iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL)?.nilIfEmpty + } +} + +struct Article: Identifiable, Codable, Hashable { + let id: Int + let feedID: Int + let feedTitle: String? + let title: String + let url: String + let imageURL: String? + let publishedAt: String + var isRead: Bool + var isFavorite: Bool + var isHidden: Bool + var isReadLater: Bool + var translatedTitle: String? + var summary: String? + + enum CodingKeys: String, CodingKey { + case id, title, url, summary + case feedID = "feed_id" + case feedTitle = "feed_title" + case imageURL = "image_url" + case publishedAt = "published_at" + case isRead = "is_read" + case isFavorite = "is_favorite" + case isHidden = "is_hidden" + case isReadLater = "is_read_later" + case translatedTitle = "translated_title" + } + + init( + id: Int, + feedID: Int, + feedTitle: String? = nil, + title: String, + url: String, + imageURL: String? = nil, + publishedAt: String, + isRead: Bool = false, + isFavorite: Bool = false, + isHidden: Bool = false, + isReadLater: Bool = false, + translatedTitle: String? = nil, + summary: String? = nil + ) { + self.id = id + self.feedID = feedID + self.feedTitle = feedTitle?.nilIfEmpty + self.title = title + self.url = url + self.imageURL = imageURL?.nilIfEmpty + self.publishedAt = publishedAt + self.isRead = isRead + self.isFavorite = isFavorite + self.isHidden = isHidden + self.isReadLater = isReadLater + self.translatedTitle = translatedTitle?.nilIfEmpty + self.summary = summary?.nilIfEmpty + } +} + +struct UnreadCounts: Codable, Equatable { + let total: Int + let feedCounts: [Int: Int] + + enum CodingKeys: String, CodingKey { + case total + case feedCounts = "feed_counts" + } + + static let empty = UnreadCounts(total: 0, feedCounts: [:]) + + init(total: Int, feedCounts: [Int: Int]) { + self.total = total + self.feedCounts = feedCounts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeIfPresent(Int.self, forKey: .total) ?? 0 + + let rawCounts = try container.decodeIfPresent([String: Int].self, forKey: .feedCounts) ?? [:] + feedCounts = Dictionary(uniqueKeysWithValues: rawCounts.compactMap { key, value in + Int(key).map { ($0, value) } + }) + } +} + +struct ArticleContent: Codable, Equatable { + let content: String + let feedURL: String? + + enum CodingKeys: String, CodingKey { + case content + case feedURL = "feed_url" + } +} + +struct RefreshProgress: Codable, Equatable { + let isRunning: Bool + + enum CodingKeys: String, CodingKey { + case isRunning = "is_running" + } +} + +struct TitleTranslationResponse: Codable, Equatable { + let translatedTitle: String + let limitReached: Bool + + enum CodingKeys: String, CodingKey { + case translatedTitle = "translated_title" + case limitReached = "limit_reached" + } +} + +struct TextTranslationResponse: Codable, Equatable { + let translatedText: String + let html: String + + enum CodingKeys: String, CodingKey { + case translatedText = "translated_text" + case html + } +} + +struct SummaryResult: Codable, Equatable { + let summary: String + let html: String? + let sentenceCount: Int? + let isTooShort: Bool + let limitReached: Bool? + let usedFallback: Bool? + let thinking: String? + let error: String? + let cached: Bool? + + enum CodingKeys: String, CodingKey { + case summary, html, thinking, error, cached + case sentenceCount = "sentence_count" + case isTooShort = "is_too_short" + case limitReached = "limit_reached" + case usedFallback = "used_fallback" + } +} + +struct RuleCondition: Codable, Hashable, Identifiable { + var id: Int + var logic: String? + var negate: Bool + var field: String + var `operator`: String + var value: String + var values: [String] + + static func empty(id: Int = Int(Date().timeIntervalSince1970 * 1_000)) -> RuleCondition { + RuleCondition( + id: id, + logic: "and", + negate: false, + field: "article_title", + operator: "contains", + value: "", + values: [] + ) + } +} + +struct AutomationRule: Codable, Hashable, Identifiable { + var id: Int + var name: String + var enabled: Bool + var conditions: [RuleCondition] + var actions: [String] + + static func empty(id: Int = Int(Date().timeIntervalSince1970 * 1_000)) -> AutomationRule { + AutomationRule(id: id, name: "New Rule", enabled: true, conditions: [], actions: []) + } +} + +struct RuleApplicationResult: Codable, Equatable { + let success: Bool + let affected: Int +} + +struct AIUsage: Codable, Equatable { + let usage: Int + let limit: Int + let limitReached: Bool + + enum CodingKeys: String, CodingKey { + case usage, limit + case limitReached = "limit_reached" + } +} + +private extension String { + var nilIfEmpty: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/frontend-swift/Sources/Models/SettingsCatalog.swift b/frontend-swift/Sources/Models/SettingsCatalog.swift new file mode 100644 index 00000000..1b676976 --- /dev/null +++ b/frontend-swift/Sources/Models/SettingsCatalog.swift @@ -0,0 +1,173 @@ +import Foundation + +enum SettingsPane: String, CaseIterable, Identifiable { + case connection + case general + case reading + case translation + case summaryAI + case storageNetwork + case integrations + case rules + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .connection: "Connection" + case .general: "General" + case .reading: "Reading" + case .translation: "Translation" + case .summaryAI: "Summary & AI" + case .storageNetwork: "Storage & Network" + case .integrations: "Integrations" + case .rules: "Rules" + case .advanced: "All Settings" + } + } + + var icon: String { + switch self { + case .connection: "server.rack" + case .general: "gearshape" + case .reading: "text.book.closed" + case .translation: "character.book.closed" + case .summaryAI: "sparkles" + case .storageNetwork: "externaldrive.connected.to.line.below" + case .integrations: "link" + case .rules: "bolt.badge.clock" + case .advanced: "slider.horizontal.3" + } + } +} + +enum SettingControl { + case toggle + case text + case secure + case picker([(String, String)]) +} + +struct SettingDefinition: Identifiable { + let key: String + let pane: SettingsPane + let section: String + let control: SettingControl + let detail: String? + + var id: String { key } + var title: String { key.settingTitle } +} + +enum SettingsCatalog { + static let definitions: [SettingDefinition] = [ + d("update_interval", .general, "Updates", .text, "Automatic refresh interval in minutes."), + d("refresh_mode", .general, "Updates", .picker([("fixed", "Fixed interval"), ("smart", "Intelligent")])), + d("language", .general, "Appearance", .picker([("en-US", "English"), ("zh-CN", "简体中文")])), + d("theme", .general, "Appearance", .picker([("auto", "System"), ("light", "Light"), ("dark", "Dark")])), + d("startup_on_boot", .general, "Application", .toggle), + d("close_to_tray", .general, "Application", .toggle), + d("auto_update", .general, "Application", .toggle), + d("shortcuts_enabled", .general, "Application", .toggle), + d("image_gallery_enabled", .general, "Application", .toggle), + + d("default_view_mode", .reading, "Article display", .picker([("rendered", "Rendered content"), ("webpage", "Original webpage")])), + d("show_hidden_articles", .reading, "Article display", .toggle), + d("hover_mark_as_read", .reading, "Article display", .toggle), + d("show_article_preview_images", .reading, "Article display", .toggle), + d("full_text_fetch_enabled", .reading, "Full text", .toggle), + d("auto_show_all_content", .reading, "Full text", .toggle), + d("custom_css_file", .reading, "Customization", .text), + + d("translation_enabled", .translation, "Translation", .toggle), + d("target_language", .translation, "Translation", .picker([("zh", "Chinese"), ("en", "English"), ("ja", "Japanese"), ("ko", "Korean"), ("fr", "French"), ("de", "German"), ("es", "Spanish")])), + d("translation_provider", .translation, "Translation", .picker([("google", "Google"), ("deepl", "DeepL"), ("baidu", "Baidu"), ("ai", "AI")])), + d("google_translate_endpoint", .translation, "Google", .text), + d("deepl_endpoint", .translation, "DeepL", .text), + d("deepl_api_key", .translation, "DeepL", .secure), + d("baidu_app_id", .translation, "Baidu", .text), + d("baidu_secret_key", .translation, "Baidu", .secure), + + d("summary_enabled", .summaryAI, "Summary", .toggle), + d("summary_length", .summaryAI, "Summary", .picker([("short", "Short"), ("medium", "Medium"), ("long", "Long")])), + d("summary_provider", .summaryAI, "Summary", .picker([("local", "Local TextRank"), ("ai", "AI")])), + d("summary_trigger_mode", .summaryAI, "Summary", .picker([("manual", "Manual"), ("auto", "Automatic")])), + d("ai_api_key", .summaryAI, "AI provider", .secure), + d("ai_endpoint", .summaryAI, "AI provider", .text), + d("ai_model", .summaryAI, "AI provider", .text), + d("ai_usage_limit", .summaryAI, "AI provider", .text), + d("ai_chat_enabled", .summaryAI, "AI provider", .toggle), + d("ai_custom_headers", .summaryAI, "AI prompts", .text), + d("ai_translation_prompt", .summaryAI, "AI prompts", .text), + d("ai_summary_prompt", .summaryAI, "AI prompts", .text), + + d("auto_cleanup_enabled", .storageNetwork, "Storage", .toggle), + d("max_cache_size_mb", .storageNetwork, "Storage", .text), + d("max_article_age_days", .storageNetwork, "Storage", .text), + d("media_cache_enabled", .storageNetwork, "Media cache", .toggle), + d("media_proxy_fallback", .storageNetwork, "Media cache", .toggle), + d("media_cache_max_size_mb", .storageNetwork, "Media cache", .text), + d("media_cache_max_age_days", .storageNetwork, "Media cache", .text), + d("proxy_enabled", .storageNetwork, "Proxy", .toggle), + d("proxy_type", .storageNetwork, "Proxy", .picker([("http", "HTTP"), ("https", "HTTPS"), ("socks5", "SOCKS5")])), + d("proxy_host", .storageNetwork, "Proxy", .text), + d("proxy_port", .storageNetwork, "Proxy", .text), + d("proxy_username", .storageNetwork, "Proxy", .secure), + d("proxy_password", .storageNetwork, "Proxy", .secure), + d("max_concurrent_refreshes", .storageNetwork, "Network", .text), + d("retry_timeout_seconds", .storageNetwork, "Network", .text), + + d("obsidian_enabled", .integrations, "Obsidian", .toggle), + d("obsidian_vault", .integrations, "Obsidian", .text), + d("obsidian_vault_path", .integrations, "Obsidian", .text), + d("freshrss_enabled", .integrations, "FreshRSS", .toggle), + d("freshrss_server_url", .integrations, "FreshRSS", .text), + d("freshrss_username", .integrations, "FreshRSS", .text), + d("freshrss_api_password", .integrations, "FreshRSS", .secure), + d("freshrss_auto_sync_interval", .integrations, "FreshRSS", .text), + d("freshrss_sync_on_startup", .integrations, "FreshRSS", .toggle) + ] + + static let secureKeys: Set = [ + "ai_api_key", "baidu_secret_key", "deepl_api_key", "freshrss_api_password", + "proxy_password", "proxy_username" + ] + + static let boolKeys: Set = Set(definitions.compactMap { definition in + if case .toggle = definition.control { return definition.key } + return nil + }) + + static func definitions(for pane: SettingsPane) -> [SettingDefinition] { + definitions.filter { $0.pane == pane } + } + + private static func d( + _ key: String, + _ pane: SettingsPane, + _ section: String, + _ control: SettingControl, + _ detail: String? = nil + ) -> SettingDefinition { + SettingDefinition(key: key, pane: pane, section: section, control: control, detail: detail) + } +} + +private extension String { + var settingTitle: String { + split(separator: "_") + .map { word in + switch word.lowercased() { + case "ai": "AI" + case "api": "API" + case "rss": "RSS" + case "url": "URL" + case "css": "CSS" + case "mb": "MB" + default: word.capitalized + } + } + .joined(separator: " ") + } +} diff --git a/frontend-swift/Sources/MrRSSApp.swift b/frontend-swift/Sources/MrRSSApp.swift new file mode 100644 index 00000000..de61b60a --- /dev/null +++ b/frontend-swift/Sources/MrRSSApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct MrRSSApp: App { + @NSApplicationDelegateAdaptor(BackendManager.self) private var backendManager + @StateObject private var viewModel = AppViewModel(autoLoad: false) + + var body: some Scene { + WindowGroup { + ContentView(viewModel: viewModel) + .preferredColorScheme(viewModel.preferredColorScheme) + .task { + await viewModel.start() + } + } + .defaultSize(width: 1280, height: 780) + .commands { + SidebarCommands() + CommandGroup(after: .newItem) { + Button("Refresh") { + viewModel.refreshFromSources() + } + .keyboardShortcut("r", modifiers: .command) + } + } + + Settings { + SettingsRootView(viewModel: viewModel) + } + } +} diff --git a/frontend-swift/Sources/Services/APIService.swift b/frontend-swift/Sources/Services/APIService.swift new file mode 100644 index 00000000..010f770e --- /dev/null +++ b/frontend-swift/Sources/Services/APIService.swift @@ -0,0 +1,396 @@ +import Foundation + +enum ServerConfiguration { + static let storageKey = "mrrss.apiBaseURL" + static let fallbackURL = URL(string: "http://127.0.0.1:1234/api")! + + static var savedBaseURL: URL { + if let environmentValue = ProcessInfo.processInfo.environment["MRRSS_API_BASE_URL"], + let url = normalizedURL(from: environmentValue) { + return url + } + + if let savedValue = UserDefaults.standard.string(forKey: storageKey), + let url = normalizedURL(from: savedValue) { + return url + } + + return fallbackURL + } + + static func normalizedURL(from value: String) -> URL? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let candidate = trimmed.contains("://") ? trimmed : "http://\(trimmed)" + guard var components = URLComponents(string: candidate), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + components.host != nil else { + return nil + } + + var path = components.path + while path.hasSuffix("/") { + path.removeLast() + } + if !path.hasSuffix("/api") { + path += "/api" + } + components.path = path + components.query = nil + components.fragment = nil + return components.url + } +} + +enum APIError: LocalizedError, Equatable { + case invalidURL + case invalidResponse + case server(statusCode: Int, message: String) + case decoding(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "The server address is invalid." + case .invalidResponse: + return "The server returned an invalid response." + case .server(let statusCode, let message): + return message.isEmpty + ? "The server returned HTTP \(statusCode)." + : "The server returned HTTP \(statusCode): \(message)" + case .decoding(let message): + return "The server response could not be read: \(message)" + } + } +} + +protocol APIClient: AnyObject { + var baseURL: URL { get } + func checkConnection() async throws + func fetchFeeds() async throws -> [Feed] + func fetchUnreadCounts() async throws -> UnreadCounts + func addFeed(url: String, title: String, category: String) async throws + func deleteFeed(id: Int) async throws + func refreshAllFeeds() async throws + func fetchRefreshProgress() async throws -> RefreshProgress + func fetchArticles( + feedID: Int?, + category: String?, + filter: String, + page: Int, + limit: Int + ) async throws -> [Article] + func setArticleRead(id: Int, read: Bool) async throws + func toggleFavorite(id: Int) async throws + func fetchArticleContent(id: Int) async throws -> ArticleContent + func fetchSettings() async throws -> [String: String] + func updateSettings(_ settings: [String: String]) async throws + func translateTitle(articleID: Int, title: String, targetLanguage: String) async throws -> TitleTranslationResponse + func translateText(_ text: String, targetLanguage: String) async throws -> TextTranslationResponse + func summarize(articleID: Int, length: String, content: String?) async throws -> SummaryResult + func clearTranslations() async throws + func clearSummaries() async throws + func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult + func fetchAIUsage() async throws -> AIUsage + func resetAIUsage() async throws +} + +final class APIService: APIClient { + static let shared = APIService() + + private(set) var baseURL: URL + private let session: URLSession + + init(baseURL: URL = ServerConfiguration.savedBaseURL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + func updateBaseURL(_ url: URL, persist: Bool = true) { + baseURL = url + if persist { + UserDefaults.standard.set(url.absoluteString, forKey: ServerConfiguration.storageKey) + } + } + + func checkConnection() async throws { + let url = try makeURL(endpoint: "version") + _ = try await data(for: URLRequest(url: url)) + } + + func fetchFeeds() async throws -> [Feed] { + try await get("feeds") + } + + func fetchUnreadCounts() async throws -> UnreadCounts { + try await get("articles/unread-counts") + } + + func addFeed(url: String, title: String, category: String) async throws { + try await post( + "feeds/add", + jsonBody: [ + "url": url, + "title": title, + "category": category + ] + ) + } + + func deleteFeed(id: Int) async throws { + try await post( + "feeds/delete", + queryItems: [URLQueryItem(name: "id", value: String(id))] + ) + } + + func refreshAllFeeds() async throws { + try await post("refresh") + } + + func fetchRefreshProgress() async throws -> RefreshProgress { + try await get("progress") + } + + func fetchArticles( + feedID: Int? = nil, + category: String? = nil, + filter: String, + page: Int = 1, + limit: Int = 50 + ) async throws -> [Article] { + var queryItems = [ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "filter", value: filter) + ] + + if let feedID { + queryItems.append(URLQueryItem(name: "feed_id", value: String(feedID))) + } + if let category, !category.isEmpty { + queryItems.append(URLQueryItem(name: "category", value: category)) + } + + return try await get("articles", queryItems: queryItems) + } + + func setArticleRead(id: Int, read: Bool) async throws { + try await post( + "articles/read", + queryItems: [ + URLQueryItem(name: "id", value: String(id)), + URLQueryItem(name: "read", value: String(read)) + ] + ) + } + + func toggleFavorite(id: Int) async throws { + try await post( + "articles/favorite", + queryItems: [URLQueryItem(name: "id", value: String(id))] + ) + } + + func fetchArticleContent(id: Int) async throws -> ArticleContent { + try await get( + "articles/content", + queryItems: [URLQueryItem(name: "id", value: String(id))] + ) + } + + func fetchSettings() async throws -> [String: String] { + try await get("settings") + } + + func updateSettings(_ settings: [String: String]) async throws { + try await sendJSON("settings", body: settings) + } + + func translateTitle( + articleID: Int, + title: String, + targetLanguage: String + ) async throws -> TitleTranslationResponse { + struct Request: Encodable { + let articleID: Int + let title: String + let targetLanguage: String + + enum CodingKeys: String, CodingKey { + case articleID = "article_id" + case title + case targetLanguage = "target_language" + } + } + + return try await postJSON( + "articles/translate", + body: Request(articleID: articleID, title: title, targetLanguage: targetLanguage) + ) + } + + func translateText(_ text: String, targetLanguage: String) async throws -> TextTranslationResponse { + struct Request: Encodable { + let text: String + let targetLanguage: String + + enum CodingKeys: String, CodingKey { + case text + case targetLanguage = "target_language" + } + } + + return try await postJSON( + "articles/translate-text", + body: Request(text: text, targetLanguage: targetLanguage) + ) + } + + func summarize(articleID: Int, length: String, content: String?) async throws -> SummaryResult { + struct Request: Encodable { + let articleID: Int + let length: String + let content: String? + + enum CodingKeys: String, CodingKey { + case articleID = "article_id" + case length, content + } + } + + return try await postJSON( + "articles/summarize", + body: Request(articleID: articleID, length: length, content: content) + ) + } + + func clearTranslations() async throws { + try await post("articles/clear-translations") + } + + func clearSummaries() async throws { + try await send("articles/clear-summaries", method: "DELETE") + } + + func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult { + try await postJSON("rules/apply", body: rule) + } + + func fetchAIUsage() async throws -> AIUsage { + try await get("ai-usage") + } + + func resetAIUsage() async throws { + try await post("ai-usage/reset") + } + + private func get( + _ endpoint: String, + queryItems: [URLQueryItem] = [] + ) async throws -> T { + let url = try makeURL(endpoint: endpoint, queryItems: queryItems) + let data = try await data(for: URLRequest(url: url)) + + if data.trimmingWhitespace == Data("null".utf8), + let emptyArray = [] as? T { + return emptyArray + } + + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw APIError.decoding(error.localizedDescription) + } + } + + private func post( + _ endpoint: String, + queryItems: [URLQueryItem] = [], + jsonBody: [String: String]? = nil + ) async throws { + let url = try makeURL(endpoint: endpoint, queryItems: queryItems) + var request = URLRequest(url: url) + request.httpMethod = "POST" + if let jsonBody { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody) + } + _ = try await data(for: request) + } + + private func postJSON( + _ endpoint: String, + body: Body + ) async throws -> Response { + let url = try makeURL(endpoint: endpoint) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let responseData = try await data(for: request) + do { + return try JSONDecoder().decode(Response.self, from: responseData) + } catch { + throw APIError.decoding(error.localizedDescription) + } + } + + private func sendJSON( + _ endpoint: String, + method: String = "POST", + body: Body + ) async throws { + let url = try makeURL(endpoint: endpoint) + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + _ = try await data(for: request) + } + + private func send(_ endpoint: String, method: String) async throws { + let url = try makeURL(endpoint: endpoint) + var request = URLRequest(url: url) + request.httpMethod = method + _ = try await data(for: request) + } + + private func makeURL( + endpoint: String, + queryItems: [URLQueryItem] = [] + ) throws -> URL { + let cleanEndpoint = endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let endpointURL = baseURL.appendingPathComponent(cleanEndpoint) + guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else { + throw APIError.invalidURL + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + guard let url = components.url else { + throw APIError.invalidURL + } + return url + } + + private func data(for request: URLRequest) async throws -> Data { + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + guard (200...299).contains(response.statusCode) else { + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + throw APIError.server(statusCode: response.statusCode, message: message) + } + return data + } +} + +private extension Data { + var trimmingWhitespace: Data { + guard let string = String(data: self, encoding: .utf8) else { return self } + return Data(string.trimmingCharacters(in: .whitespacesAndNewlines).utf8) + } +} diff --git a/frontend-swift/Sources/Services/BackendManager.swift b/frontend-swift/Sources/Services/BackendManager.swift new file mode 100644 index 00000000..11d6d552 --- /dev/null +++ b/frontend-swift/Sources/Services/BackendManager.swift @@ -0,0 +1,52 @@ +import AppKit +import Foundation + +final class BackendManager: NSObject, NSApplicationDelegate { + private var process: Process? + + func applicationDidFinishLaunching(_ notification: Notification) { + startBundledBackendIfNeeded() + } + + func applicationWillTerminate(_ notification: Notification) { + stopBundledBackend() + } + + private func startBundledBackendIfNeeded() { + let baseURL = ServerConfiguration.savedBaseURL + guard ["127.0.0.1", "localhost"].contains(baseURL.host?.lowercased() ?? ""), + baseURL.port ?? 80 == 1234, + let executableURL = Bundle.main.url(forResource: "mrrss-server", withExtension: nil) else { + return + } + + let supportDirectory = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0].appendingPathComponent("MrRSS-SwiftUI", isDirectory: true) + + do { + try FileManager.default.createDirectory( + at: supportDirectory.appendingPathComponent("data", isDirectory: true), + withIntermediateDirectories: true + ) + let process = Process() + process.executableURL = executableURL + process.arguments = ["-host", "127.0.0.1", "-port", "1234"] + process.currentDirectoryURL = supportDirectory + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + self.process = process + } catch { + NSLog("Unable to start bundled MrRSS backend: %@", error.localizedDescription) + } + } + + private func stopBundledBackend() { + guard let process, process.isRunning else { return } + process.terminate() + process.waitUntilExit() + self.process = nil + } +} diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift new file mode 100644 index 00000000..656feb44 --- /dev/null +++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift @@ -0,0 +1,527 @@ +import Foundation +import SwiftUI + +enum ArticleFilter: String, CaseIterable, Identifiable { + case all + case unread + case favorites + case readLater + + var id: String { rawValue } + + var title: String { + switch self { + case .all: "All Articles" + case .unread: "Unread" + case .favorites: "Favorites" + case .readLater: "Read Later" + } + } + + var icon: String { + switch self { + case .all: "rectangle.stack" + case .unread: "circle.fill" + case .favorites: "star.fill" + case .readLater: "clock.fill" + } + } +} + +enum SidebarItem: Hashable { + case filter(ArticleFilter) + case feed(Int) +} + +enum ConnectionState: Equatable { + case connecting + case connected + case disconnected + + var title: String { + switch self { + case .connecting: "Connecting" + case .connected: "Connected" + case .disconnected: "Offline" + } + } + + var color: Color { + switch self { + case .connecting: .orange + case .connected: .green + case .disconnected: .red + } + } +} + +@MainActor +final class AppViewModel: ObservableObject { + @Published private(set) var feeds: [Feed] = [] + @Published private(set) var articles: [Article] = [] + @Published private(set) var unreadCounts = UnreadCounts.empty + @Published private(set) var isLoadingArticles = false + @Published private(set) var isLoadingFeeds = false + @Published private(set) var isRefreshingSources = false + @Published private(set) var connectionState: ConnectionState = .connecting + @Published private(set) var settings: [String: String] = [:] + @Published private(set) var rules: [AutomationRule] = [] + @Published private(set) var aiUsage: AIUsage? + @Published private(set) var isLoadingSettings = false + @Published private(set) var isSavingSettings = false + @Published var statusMessage: String? + @Published var errorMessage: String? + @Published var selectedArticleID: Int? + @Published var serverURLText: String + + @Published var selection: SidebarItem? = .filter(.all) { + didSet { + guard selection != oldValue else { return } + selectedArticleID = nil + reloadArticles() + } + } + + private var page = 0 + private var hasMore = true + private let limit: Int + private var api: APIClient + private var feedTask: Task? + private var sourceRefreshTask: Task? + private var feedRequestID = UUID() + private var articleTask: Task? + private var articleRequestID = UUID() + + init(api: APIClient = APIService.shared, limit: Int = 50, autoLoad: Bool = true) { + self.api = api + self.limit = limit + serverURLText = api.baseURL.absoluteString + + if autoLoad { + refreshAll() + } + } + + func refreshAll() { + refreshFeeds() + reloadArticles() + Task { await loadSettings() } + } + + func start() async { + connectionState = .connecting + for attempt in 0..<30 { + do { + try await api.checkConnection() + connectionState = .connected + refreshAll() + return + } catch { + if attempt == 29 { + connectionState = .disconnected + errorMessage = error.localizedDescription + return + } + try? await Task.sleep(for: .milliseconds(200)) + } + } + } + + func refreshFeeds() { + feedTask?.cancel() + feedRequestID = UUID() + let requestID = feedRequestID + isLoadingFeeds = true + connectionState = .connecting + + feedTask = Task { [weak self] in + guard let self else { return } + do { + async let feedsRequest = api.fetchFeeds() + async let countsRequest = api.fetchUnreadCounts() + let (loadedFeeds, loadedCounts) = try await (feedsRequest, countsRequest) + try Task.checkCancellation() + guard requestID == feedRequestID else { return } + feeds = loadedFeeds + unreadCounts = loadedCounts + connectionState = .connected + isLoadingFeeds = false + } catch is CancellationError { + guard requestID == feedRequestID else { return } + isLoadingFeeds = false + } catch { + guard requestID == feedRequestID else { return } + connectionState = .disconnected + errorMessage = error.localizedDescription + isLoadingFeeds = false + } + } + } + + func refreshFromSources() { + guard !isRefreshingSources else { return } + sourceRefreshTask?.cancel() + isRefreshingSources = true + + sourceRefreshTask = Task { [weak self] in + guard let self else { return } + do { + try await api.refreshAllFeeds() + + for _ in 0..<180 { + try Task.checkCancellation() + let progress = try await api.fetchRefreshProgress() + if !progress.isRunning { break } + try await Task.sleep(for: .seconds(1)) + } + + try Task.checkCancellation() + isRefreshingSources = false + refreshAll() + } catch is CancellationError { + isRefreshingSources = false + } catch { + isRefreshingSources = false + errorMessage = error.localizedDescription + } + } + } + + func addFeed(url: String, title: String, category: String) async -> Bool { + guard let parsedURL = URL(string: url), + let scheme = parsedURL.scheme?.lowercased(), + ["http", "https"].contains(scheme), + parsedURL.host != nil else { + errorMessage = "Enter a valid HTTP or HTTPS feed URL." + return false + } + + do { + try await api.addFeed( + url: url.trimmingCharacters(in: .whitespacesAndNewlines), + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + category: category.trimmingCharacters(in: .whitespacesAndNewlines) + ) + refreshAll() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func deleteFeed(_ feed: Feed) async { + do { + try await api.deleteFeed(id: feed.id) + if selection == .feed(feed.id) { + selection = .filter(.all) + } + refreshAll() + } catch { + errorMessage = error.localizedDescription + } + } + + func reloadArticles() { + articleTask?.cancel() + articleRequestID = UUID() + page = 0 + hasMore = true + fetchPage(1, replacing: true) + } + + func selectArticle(_ article: Article) { + selectedArticleID = article.id + } + + func loadMore() { + guard hasMore, !isLoadingArticles else { return } + fetchPage(page + 1, replacing: false) + } + + func setArticleRead(_ article: Article, read: Bool) { + guard article.isRead != read, + let index = articles.firstIndex(where: { $0.id == article.id }) else { + return + } + + let previousArticle = articles[index] + articles[index].isRead = read + if read { + articles[index].isReadLater = false + } + + Task { [weak self] in + guard let self else { return } + do { + try await api.setArticleRead(id: article.id, read: read) + unreadCounts = try await api.fetchUnreadCounts() + } catch { + if let currentIndex = articles.firstIndex(where: { $0.id == article.id }), + articles[currentIndex].isRead == read { + articles[currentIndex] = previousArticle + } + errorMessage = error.localizedDescription + } + } + } + + func setArticleRead(id: Int, read: Bool) { + guard let article = articles.first(where: { $0.id == id }) else { return } + setArticleRead(article, read: read) + } + + func toggleFavorite(_ article: Article) { + guard let index = articles.firstIndex(where: { $0.id == article.id }) else { return } + let previousValue = articles[index].isFavorite + articles[index].isFavorite.toggle() + + Task { [weak self] in + guard let self else { return } + do { + try await api.toggleFavorite(id: article.id) + } catch { + if let currentIndex = articles.firstIndex(where: { $0.id == article.id }), + articles[currentIndex].isFavorite != previousValue { + articles[currentIndex].isFavorite = previousValue + } + errorMessage = error.localizedDescription + } + } + } + + @discardableResult + func saveServerAddress() -> Bool { + guard let url = ServerConfiguration.normalizedURL(from: serverURLText) else { + errorMessage = "Enter a valid HTTP or HTTPS server address." + return false + } + + UserDefaults.standard.set(url.absoluteString, forKey: ServerConfiguration.storageKey) + api = APIService(baseURL: url) + serverURLText = url.absoluteString + selectedArticleID = nil + errorMessage = nil + refreshAll() + return true + } + + func clearError() { + errorMessage = nil + } + + func article(withID id: Int?) -> Article? { + guard let id else { return nil } + return articles.first(where: { $0.id == id }) + } + + func fetchArticleContent(id: Int) async throws -> ArticleContent { + try await api.fetchArticleContent(id: id) + } + + func loadSettings() async { + guard !isLoadingSettings else { return } + isLoadingSettings = true + do { + settings = try await api.fetchSettings() + decodeRules() + aiUsage = try? await api.fetchAIUsage() + } catch { + errorMessage = error.localizedDescription + } + isLoadingSettings = false + } + + func setting(_ key: String, default defaultValue: String = "") -> String { + settings[key] ?? defaultValue + } + + func updateSetting(_ key: String, value: String) { + settings[key] = value + } + + func boolSetting(_ key: String, default defaultValue: Bool = false) -> Bool { + guard let value = settings[key] else { return defaultValue } + return value == "true" || value == "1" + } + + func updateBoolSetting(_ key: String, value: Bool) { + settings[key] = value ? "true" : "false" + } + + @discardableResult + func saveSettings() async -> Bool { + guard !isSavingSettings else { return false } + isSavingSettings = true + do { + try await api.updateSettings(settings) + statusMessage = "Settings saved." + decodeRules() + isSavingSettings = false + return true + } catch { + errorMessage = error.localizedDescription + isSavingSettings = false + return false + } + } + + func saveRules(_ newRules: [AutomationRule]) async -> Bool { + do { + let data = try JSONEncoder().encode(newRules) + guard let json = String(data: data, encoding: .utf8) else { return false } + rules = newRules + settings["rules"] = json + return await saveSettings() + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func applyRule(_ rule: AutomationRule) async -> RuleApplicationResult? { + do { + let result = try await api.applyRule(rule) + statusMessage = "Rule applied to \(result.affected) articles." + refreshAll() + return result + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + func translateTitle(for article: Article) async throws -> String { + let language = setting("target_language", default: "zh") + let result = try await api.translateTitle( + articleID: article.id, + title: article.title, + targetLanguage: language + ) + if let index = articles.firstIndex(where: { $0.id == article.id }) { + articles[index].translatedTitle = result.translatedTitle + } + if result.limitReached { + statusMessage = "The AI usage limit was reached; a fallback provider was used." + } + return result.translatedTitle + } + + func translateContent(_ content: String) async throws -> TextTranslationResponse { + try await api.translateText( + content, + targetLanguage: setting("target_language", default: "zh") + ) + } + + func summarize(article: Article, content: String?) async throws -> SummaryResult { + let result = try await api.summarize( + articleID: article.id, + length: setting("summary_length", default: "medium"), + content: content + ) + if let index = articles.firstIndex(where: { $0.id == article.id }) { + articles[index].summary = result.summary + } + return result + } + + func clearGeneratedContent() async { + do { + try await api.clearTranslations() + try await api.clearSummaries() + statusMessage = "Cached translations and summaries cleared." + reloadArticles() + } catch { + errorMessage = error.localizedDescription + } + } + + func resetAIUsage() async { + do { + try await api.resetAIUsage() + aiUsage = try await api.fetchAIUsage() + statusMessage = "AI usage reset." + } catch { + errorMessage = error.localizedDescription + } + } + + var preferredColorScheme: ColorScheme? { + switch setting("theme", default: "auto") { + case "light": .light + case "dark": .dark + default: nil + } + } + + func clearStatusMessage() { + statusMessage = nil + } + + private func decodeRules() { + guard let rawRules = settings["rules"], !rawRules.isEmpty, + let data = rawRules.data(using: .utf8), + let decoded = try? JSONDecoder().decode([AutomationRule].self, from: data) else { + rules = [] + return + } + rules = decoded + } + + private func fetchPage(_ targetPage: Int, replacing: Bool) { + guard !isLoadingArticles || replacing else { return } + + let requestID = articleRequestID + let query = articleQuery + isLoadingArticles = true + if replacing { + connectionState = .connecting + } + + articleTask = Task { [weak self] in + guard let self else { return } + do { + let loadedArticles = try await api.fetchArticles( + feedID: query.feedID, + category: nil, + filter: query.filter, + page: targetPage, + limit: limit + ) + try Task.checkCancellation() + guard requestID == articleRequestID else { return } + + if replacing { + articles = loadedArticles + } else { + let existingIDs = Set(articles.map(\.id)) + articles.append(contentsOf: loadedArticles.filter { !existingIDs.contains($0.id) }) + } + page = targetPage + hasMore = loadedArticles.count == limit + connectionState = .connected + isLoadingArticles = false + } catch is CancellationError { + guard requestID == articleRequestID else { return } + isLoadingArticles = false + } catch { + guard requestID == articleRequestID else { return } + connectionState = .disconnected + errorMessage = error.localizedDescription + isLoadingArticles = false + } + } + } + + private var articleQuery: (feedID: Int?, filter: String) { + switch selection { + case .filter(let filter): + return (nil, filter.rawValue) + case .feed(let id): + return (id, "") + case .none: + return (nil, ArticleFilter.all.rawValue) + } + } +} diff --git a/frontend-swift/Sources/Views/AddFeedView.swift b/frontend-swift/Sources/Views/AddFeedView.swift new file mode 100644 index 00000000..943c6860 --- /dev/null +++ b/frontend-swift/Sources/Views/AddFeedView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct AddFeedView: View { + @ObservedObject var viewModel: AppViewModel + @Environment(\.dismiss) private var dismiss + + @State private var url = "" + @State private var title = "" + @State private var category = "" + @State private var isSubmitting = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 5) { + Text("Add Feed") + .font(.title2.bold()) + Text("Subscribe using an RSS, Atom, or compatible feed URL.") + .foregroundStyle(.secondary) + } + + Form { + TextField("Feed URL", text: $url, prompt: Text("https://example.com/feed.xml")) + TextField("Title (optional)", text: $title) + TextField("Category (optional)", text: $category) + } + .formStyle(.grouped) + + HStack { + Spacer() + Button("Cancel", role: .cancel) { + dismiss() + } + Button("Add Feed") { + submit() + } + .keyboardShortcut(.defaultAction) + .disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + } + } + .padding(24) + .frame(width: 500, height: 300) + .overlay { + if isSubmitting { + ZStack { + Color.black.opacity(0.08) + ProgressView("Adding feed") + .padding(20) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + } + + private func submit() { + isSubmitting = true + Task { + if await viewModel.addFeed(url: url, title: title, category: category) { + dismiss() + } + isSubmitting = false + } + } +} diff --git a/frontend-swift/Sources/Views/ArticleDetailView.swift b/frontend-swift/Sources/Views/ArticleDetailView.swift new file mode 100644 index 00000000..c2f9cc13 --- /dev/null +++ b/frontend-swift/Sources/Views/ArticleDetailView.swift @@ -0,0 +1,339 @@ +import SwiftUI +import AppKit + +struct ArticleDetailView: View { + let article: Article + @ObservedObject var viewModel: AppViewModel + + @State private var articleContent = ArticleContent(content: "", feedURL: nil) + @State private var isLoading = false + @State private var loadError: String? + @State private var translatedTitle: String? + @State private var translatedContent: TextTranslationResponse? + @State private var summaryResult: SummaryResult? + @State private var displayTranslation = false + @State private var activeOperation: ArticleOperation? + @State private var operationError: String? + @State private var operationNotice: String? + + // NavigationSplitView measures its columns, and a column whose ideal size + // follows the article text makes the split view lay itself out around that + // size instead of around the window, which pushes the sidebar and the + // article list out of the visible area. GeometryReader keeps the reported + // size independent of the header, the summary card, and the web content. + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + articleHeader + Divider() + contentView + } + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + .background(Color(nsColor: .textBackgroundColor)) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Menu { + Button { + Task { await translateTitle() } + } label: { + Label("Translate Title", systemImage: "character.book.closed") + } + + Button { + Task { await translateArticle() } + } label: { + Label("Translate Content", systemImage: "text.bubble") + } + + Button { + Task { await summarizeArticle() } + } label: { + Label("Generate Summary", systemImage: "text.quote") + } + + if translatedContent != nil { + Divider() + Toggle("Show Translation", isOn: $displayTranslation) + } + } label: { + if activeOperation != nil { + ProgressView() + .controlSize(.small) + } else { + Label("Language Tools", systemImage: "sparkles") + } + } + .disabled(activeOperation != nil) + + Button { + viewModel.toggleFavorite(article) + } label: { + Label( + article.isFavorite ? "Remove Favorite" : "Favorite", + systemImage: article.isFavorite ? "star.fill" : "star" + ) + } + .help(article.isFavorite ? "Remove from favorites" : "Add to favorites") + + if let url = URL(string: article.url) { + Link(destination: url) { + Label("Open in Browser", systemImage: "safari") + } + .help("Open the original article in your browser") + } + } + } + .task(id: article.id) { + translatedTitle = article.translatedTitle + summaryResult = article.summary.map { + SummaryResult( + summary: $0, + html: nil, + sentenceCount: nil, + isTooShort: false, + limitReached: nil, + usedFallback: nil, + thinking: nil, + error: nil, + cached: true + ) + } + viewModel.setArticleRead(article, read: true) + await loadContent() + if viewModel.boolSetting("summary_enabled"), + viewModel.setting("summary_trigger_mode", default: "manual") == "auto", + summaryResult == nil { + await summarizeArticle() + } + } + .alert("Language Tool Error", isPresented: Binding( + get: { operationError != nil }, + set: { if !$0 { operationError = nil } } + )) { + Button("Dismiss", role: .cancel) { operationError = nil } + } message: { + Text(operationError ?? "") + } + .alert("Language Tools", isPresented: Binding( + get: { operationNotice != nil }, + set: { if !$0 { operationNotice = nil } } + )) { + Button("OK", role: .cancel) { operationNotice = nil } + } message: { + Text(operationNotice ?? "") + } + } + + private var articleHeader: some View { + VStack(alignment: .leading, spacing: 10) { + Text(translatedTitle ?? article.translatedTitle ?? article.title) + .font(.system(size: 25, weight: .bold, design: .rounded)) + .textSelection(.enabled) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + + if let translatedTitle, translatedTitle != article.title { + Text(article.title) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + + HStack(spacing: 8) { + if let feedTitle = article.feedTitle { + Text(feedTitle) + .fontWeight(.medium) + .foregroundStyle(.tint) + } + + Text(ArticleDateFormatter.fullString(from: article.publishedAt)) + .foregroundStyle(.secondary) + + Spacer() + } + .font(.subheadline) + } + .padding(.horizontal, 28) + .padding(.top, 24) + .padding(.bottom, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } + + @ViewBuilder + private var contentView: some View { + if isLoading { + ProgressView("Loading article") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let loadError { + ContentUnavailableView { + Label("Unable to Load Article", systemImage: "exclamationmark.triangle") + } description: { + Text(loadError) + } actions: { + Button("Retry") { + Task { await loadContent() } + } + } + } else { + VStack(spacing: 0) { + if let summaryResult, !summaryResult.summary.isEmpty { + SummaryCard(result: summaryResult) + Divider() + } + + if translatedContent != nil { + Picker("Content", selection: $displayTranslation) { + Text("Original").tag(false) + Text("Translation").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 280) + .padding(.vertical, 8) + } + + WebView( + html: displayedContent, + baseURL: URL(string: articleContent.feedURL ?? article.url) + ) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .clipped() + } + } + } + + private var displayedContent: String { + if displayTranslation, let translatedContent { + if !translatedContent.html.isEmpty { return translatedContent.html } + return "

\(HTMLDocument.escape(translatedContent.translatedText))

" + } + if !articleContent.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return articleContent.content + } + if let summary = article.summary, !summary.isEmpty { + return "

\(HTMLDocument.escape(summary))

" + } + return "

No article content is available.

" + } + + private func loadContent() async { + isLoading = true + loadError = nil + do { + articleContent = try await viewModel.fetchArticleContent(id: article.id) + } catch is CancellationError { + return + } catch { + loadError = error.localizedDescription + } + isLoading = false + } + + private func translateTitle() async { + guard activeOperation == nil else { return } + activeOperation = .translateTitle + defer { activeOperation = nil } + do { + translatedTitle = try await viewModel.translateTitle(for: article) + if translatedTitle == article.title { + operationNotice = "The title already matches the selected target language." + } + } catch { + operationError = error.localizedDescription + } + } + + private func translateArticle() async { + guard activeOperation == nil else { return } + activeOperation = .translateContent + defer { activeOperation = nil } + do { + let source = plainText(from: articleContent.content) + translatedContent = try await viewModel.translateContent(source) + if translatedContent?.translatedText == source { + operationNotice = "The article already primarily matches the selected target language." + } else { + displayTranslation = true + } + } catch { + operationError = error.localizedDescription + } + } + + private func summarizeArticle() async { + guard activeOperation == nil else { return } + activeOperation = .summarize + defer { activeOperation = nil } + do { + summaryResult = try await viewModel.summarize( + article: article, + content: articleContent.content.isEmpty ? nil : plainText(from: articleContent.content) + ) + } catch { + operationError = error.localizedDescription + } + } + + private func plainText(from html: String) -> String { + guard let data = html.data(using: .utf8), + let attributed = try? NSAttributedString( + data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) else { + return html + } + return attributed.string + } +} + +private enum ArticleOperation { + case translateTitle + case translateContent + case summarize +} + +private struct SummaryCard: View { + let result: SummaryResult + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Summary", systemImage: "text.quote") + .font(.headline) + Spacer() + if result.cached == true { + Text("Cached") + .font(.caption) + .foregroundStyle(.secondary) + } + if result.usedFallback == true { + Text("Local fallback") + .font(.caption) + .foregroundStyle(.orange) + } + } + Text(result.summary) + .font(.callout) + .textSelection(.enabled) + .lineLimit(8) + .fixedSize(horizontal: false, vertical: true) + if let error = result.error, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.06)) + } +} diff --git a/frontend-swift/Sources/Views/ArticleListView.swift b/frontend-swift/Sources/Views/ArticleListView.swift new file mode 100644 index 00000000..7e3da44d --- /dev/null +++ b/frontend-swift/Sources/Views/ArticleListView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct ArticleListView: View { + @ObservedObject var viewModel: AppViewModel + + var body: some View { + List { + ForEach(viewModel.articles) { article in + Button { + viewModel.selectArticle(article) + } label: { + ArticleRowView(article: article) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .listRowBackground( + viewModel.selectedArticleID == article.id + ? Color.accentColor.opacity(0.14) + : Color.clear + ) + .onAppear { + if article.id == viewModel.articles.last?.id { + viewModel.loadMore() + } + } + .contextMenu { + Button { + viewModel.setArticleRead(article, read: !article.isRead) + } label: { + Label( + article.isRead ? "Mark as Unread" : "Mark as Read", + systemImage: article.isRead ? "circle" : "checkmark.circle" + ) + } + + Button { + viewModel.toggleFavorite(article) + } label: { + Label( + article.isFavorite ? "Remove Favorite" : "Favorite", + systemImage: article.isFavorite ? "star.slash" : "star" + ) + } + } + } + + if viewModel.isLoadingArticles && !viewModel.articles.isEmpty { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.inset) + .overlay { + if viewModel.isLoadingArticles && viewModel.articles.isEmpty { + ProgressView("Loading articles") + } else if viewModel.articles.isEmpty { + ContentUnavailableView( + "No Articles", + systemImage: "tray", + description: Text("Refresh or choose another source.") + ) + } + } + .navigationTitle(currentTitle) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Refresh articles", systemImage: "arrow.clockwise") { + viewModel.reloadArticles() + } + .disabled(viewModel.isLoadingArticles) + .help("Reload the current article list") + } + } + } + + private var currentTitle: String { + switch viewModel.selection { + case .filter(let filter): + filter.title + case .feed(let id): + viewModel.feeds.first(where: { $0.id == id })?.title ?? "Feed" + case .none: + "Articles" + } + } +} diff --git a/frontend-swift/Sources/Views/ArticleRowView.swift b/frontend-swift/Sources/Views/ArticleRowView.swift new file mode 100644 index 00000000..4b692a27 --- /dev/null +++ b/frontend-swift/Sources/Views/ArticleRowView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct ArticleRowView: View { + let article: Article + + var body: some View { + HStack(alignment: .top, spacing: 12) { + unreadIndicator + + VStack(alignment: .leading, spacing: 7) { + Text(article.translatedTitle?.isEmpty == false ? article.translatedTitle! : article.title) + .font(.system(size: 14, weight: article.isRead ? .regular : .semibold)) + .foregroundStyle(article.isRead ? .secondary : .primary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + + if let translatedTitle = article.translatedTitle, + !translatedTitle.isEmpty, + translatedTitle != article.title { + Text(article.title) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + HStack(spacing: 6) { + if let feedTitle = article.feedTitle { + Text(feedTitle) + .lineLimit(1) + } + + if article.feedTitle != nil { + Text("·") + } + + Text(ArticleDateFormatter.relativeString(from: article.publishedAt)) + + Spacer(minLength: 4) + + if article.isReadLater { + Image(systemName: "clock.fill") + .foregroundStyle(.tint) + } + if article.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + if let imageURL = article.imageURL, + let url = URL(string: imageURL) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + imagePlaceholder + .overlay { ProgressView().controlSize(.mini) } + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + imagePlaceholder + .overlay { Image(systemName: "photo") } + @unknown default: + imagePlaceholder + } + } + .frame(width: 76, height: 58) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + } + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + + private var unreadIndicator: some View { + Circle() + .fill(article.isRead ? Color.clear : Color.accentColor) + .frame(width: 6, height: 6) + .padding(.top, 6) + } + + private var imagePlaceholder: some View { + Rectangle() + .fill(.quaternary) + .foregroundStyle(.secondary) + } +} + +enum ArticleDateFormatter { + private static let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let standardFormatter = ISO8601DateFormatter() + + static func date(from string: String) -> Date? { + fractionalFormatter.date(from: string) ?? standardFormatter.date(from: string) + } + + static func relativeString(from string: String) -> String { + guard let date = date(from: string) else { return string } + return date.formatted(.relative(presentation: .named)) + } + + static func fullString(from string: String) -> String { + guard let date = date(from: string) else { return string } + return date.formatted(date: .abbreviated, time: .shortened) + } +} diff --git a/frontend-swift/Sources/Views/ContentView.swift b/frontend-swift/Sources/Views/ContentView.swift new file mode 100644 index 00000000..597bf739 --- /dev/null +++ b/frontend-swift/Sources/Views/ContentView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct ContentView: View { + @ObservedObject var viewModel: AppViewModel + + var body: some View { + NavigationSplitView { + SidebarView(viewModel: viewModel) + .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300) + } content: { + ArticleListView(viewModel: viewModel) + .navigationSplitViewColumnWidth(min: 330, ideal: 410, max: 520) + } detail: { + if let article = viewModel.article(withID: viewModel.selectedArticleID) { + ArticleDetailView(article: article, viewModel: viewModel) + .id(article.id) + } else { + EmptyArticleView() + } + } + .frame(minWidth: 980, minHeight: 620) + .alert( + "MrRSS", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + ) + ) { + Button("Dismiss", role: .cancel) { + viewModel.clearError() + } + Button("Retry") { + viewModel.clearError() + viewModel.refreshAll() + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } +} + +private struct EmptyArticleView: View { + var body: some View { + ContentUnavailableView { + Label("Choose an article", systemImage: "newspaper") + } description: { + Text("Select an article from the list to read it here.") + } + } +} diff --git a/frontend-swift/Sources/Views/RulesSettingsView.swift b/frontend-swift/Sources/Views/RulesSettingsView.swift new file mode 100644 index 00000000..ddac0f6a --- /dev/null +++ b/frontend-swift/Sources/Views/RulesSettingsView.swift @@ -0,0 +1,286 @@ +import SwiftUI + +struct RulesSettingsView: View { + @ObservedObject var viewModel: AppViewModel + @State private var editingRule: AutomationRule? + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Automation Rules") + .font(.title2.weight(.semibold)) + Text("Match incoming or existing articles and apply actions automatically.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + editingRule = .empty() + } label: { + Label("Add Rule", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + .padding() + + Divider() + + if viewModel.rules.isEmpty { + ContentUnavailableView { + Label("No Rules", systemImage: "bolt.badge.clock") + } description: { + Text("Create a rule to favorite, hide, mark, or defer matching articles.") + } actions: { + Button("Create Rule") { editingRule = .empty() } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(viewModel.rules) { rule in + RuleRow( + rule: rule, + onToggle: { enabled in update(rule, enabled: enabled) }, + onEdit: { editingRule = rule }, + onApply: { Task { await viewModel.applyRule(rule) } }, + onDelete: { delete(rule) } + ) + } + } + } + } + .sheet(item: $editingRule) { rule in + RuleEditorView(rule: rule) { savedRule in + save(savedRule) + } + } + } + + private func update(_ rule: AutomationRule, enabled: Bool) { + var updated = rule + updated.enabled = enabled + save(updated) + } + + private func save(_ rule: AutomationRule) { + var rules = viewModel.rules + if let index = rules.firstIndex(where: { $0.id == rule.id }) { + rules[index] = rule + } else { + rules.append(rule) + } + Task { await viewModel.saveRules(rules) } + } + + private func delete(_ rule: AutomationRule) { + Task { await viewModel.saveRules(viewModel.rules.filter { $0.id != rule.id }) } + } +} + +private struct RuleRow: View { + let rule: AutomationRule + let onToggle: (Bool) -> Void + let onEdit: () -> Void + let onApply: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 12) { + Toggle("", isOn: Binding(get: { rule.enabled }, set: onToggle)) + .labelsHidden() + + VStack(alignment: .leading, spacing: 4) { + Text(rule.name) + .font(.headline) + Text("\(rule.conditions.count) conditions · \(rule.actions.count) actions") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Apply Now", action: onApply) + Button("Edit", action: onEdit) + Menu { + Button("Delete", role: .destructive, action: onDelete) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.vertical, 5) + } +} + +private struct RuleEditorView: View { + @Environment(\.dismiss) private var dismiss + @State var rule: AutomationRule + let onSave: (AutomationRule) -> Void + + private let fields = [ + ("article_title", "Article title"), + ("feed_name", "Feed name"), + ("feed_category", "Feed category"), + ("feed_type", "Feed type"), + ("is_freshrss_feed", "FreshRSS feed"), + ("is_image_mode_feed", "Image-mode feed"), + ("published_after", "Published after"), + ("published_before", "Published before"), + ("is_read", "Read state"), + ("is_favorite", "Favorite state"), + ("is_hidden", "Hidden state"), + ("is_read_later", "Read-later state") + ] + + private let actions = [ + ("favorite", "Add favorite"), + ("unfavorite", "Remove favorite"), + ("hide", "Hide"), + ("unhide", "Unhide"), + ("mark_read", "Mark read"), + ("mark_unread", "Mark unread"), + ("read_later", "Add to read later"), + ("remove_read_later", "Remove from read later") + ] + + var body: some View { + NavigationStack { + Form { + Section("Rule") { + TextField("Name", text: $rule.name) + Toggle("Enabled", isOn: $rule.enabled) + } + + Section("Conditions") { + if rule.conditions.isEmpty { + Text("No conditions means that every article matches.") + .foregroundStyle(.secondary) + } + + ForEach($rule.conditions) { $condition in + VStack(alignment: .leading, spacing: 8) { + HStack { + if condition.id != rule.conditions.first?.id { + Picker("Logic", selection: Binding( + get: { condition.logic ?? "and" }, + set: { condition.logic = $0 } + )) { + Text("AND").tag("and") + Text("OR").tag("or") + } + .labelsHidden() + .frame(width: 76) + } + + Toggle("Not", isOn: $condition.negate) + .toggleStyle(.checkbox) + + Picker("Field", selection: $condition.field) { + ForEach(fields, id: \.0) { value, title in + Text(title).tag(value) + } + } + + Button(role: .destructive) { + rule.conditions.removeAll { $0.id == condition.id } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + + conditionValueEditor($condition) + } + .padding(.vertical, 5) + } + + Button { + var condition = RuleCondition.empty(id: nextConditionID) + condition.logic = rule.conditions.isEmpty ? nil : "and" + rule.conditions.append(condition) + } label: { + Label("Add Condition", systemImage: "plus") + } + } + + Section("Actions") { + ForEach(actions, id: \.0) { value, title in + Toggle(title, isOn: Binding( + get: { rule.actions.contains(value) }, + set: { enabled in + if enabled { + if !rule.actions.contains(value) { rule.actions.append(value) } + } else { + rule.actions.removeAll { $0 == value } + } + } + )) + } + } + } + .formStyle(.grouped) + .navigationTitle("Edit Rule") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + normalizeConditions() + onSave(rule) + dismiss() + } + .disabled(rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.actions.isEmpty) + } + } + } + .frame(minWidth: 680, minHeight: 580) + } + + @ViewBuilder + private func conditionValueEditor(_ condition: Binding) -> some View { + switch condition.wrappedValue.field { + case "article_title": + HStack { + Picker("Operator", selection: condition.operator) { + Text("Contains").tag("contains") + Text("Equals").tag("exact") + Text("Regular expression").tag("regex") + } + .frame(width: 180) + TextField("Value", text: condition.value) + } + case "feed_name", "feed_category": + TextField("Comma-separated values", text: Binding( + get: { condition.wrappedValue.values.joined(separator: ", ") }, + set: { condition.wrappedValue.values = splitValues($0) } + )) + case "is_freshrss_feed", "is_image_mode_feed", "is_read", "is_favorite", "is_hidden", "is_read_later": + Picker("Value", selection: condition.value) { + Text("Yes").tag("true") + Text("No").tag("false") + } + default: + TextField(condition.wrappedValue.field.hasPrefix("published_") ? "YYYY-MM-DD" : "Value", text: condition.value) + } + } + + private var nextConditionID: Int { + max((rule.conditions.map(\.id).max() ?? 0) + 1, Int(Date().timeIntervalSince1970 * 1_000)) + } + + private func splitValues(_ text: String) -> [String] { + text.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private func normalizeConditions() { + for index in rule.conditions.indices { + rule.conditions[index].logic = index == 0 ? nil : (rule.conditions[index].logic ?? "and") + if rule.conditions[index].field == "article_title", rule.conditions[index].operator.isEmpty { + rule.conditions[index].operator = "contains" + } + } + } +} diff --git a/frontend-swift/Sources/Views/ServerSettingsView.swift b/frontend-swift/Sources/Views/ServerSettingsView.swift new file mode 100644 index 00000000..171d5a62 --- /dev/null +++ b/frontend-swift/Sources/Views/ServerSettingsView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct ServerSettingsView: View { + @ObservedObject var viewModel: AppViewModel + @State private var didSave = false + + var body: some View { + Form { + Section("Backend server") { + TextField("API address", text: $viewModel.serverURLText) + .textFieldStyle(.roundedBorder) + + Text("The default server address is http://127.0.0.1:1234/api. You can also set MRRSS_API_BASE_URL before launching the app.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Circle() + .fill(viewModel.connectionState.color) + .frame(width: 8, height: 8) + Text(viewModel.connectionState.title) + .foregroundStyle(.secondary) + Spacer() + if didSave { + Text("Saved") + .foregroundStyle(.secondary) + } + Button("Save and reconnect") { + didSave = viewModel.saveServerAddress() + } + .keyboardShortcut(.defaultAction) + } + } + .formStyle(.grouped) + .padding(8) + } +} diff --git a/frontend-swift/Sources/Views/SettingsRootView.swift b/frontend-swift/Sources/Views/SettingsRootView.swift new file mode 100644 index 00000000..59ef4a08 --- /dev/null +++ b/frontend-swift/Sources/Views/SettingsRootView.swift @@ -0,0 +1,212 @@ +import SwiftUI + +struct SettingsRootView: View { + @ObservedObject var viewModel: AppViewModel + @State private var selectedPane: SettingsPane? = .general + + var body: some View { + NavigationSplitView { + List(SettingsPane.allCases, selection: $selectedPane) { pane in + Label(pane.title, systemImage: pane.icon) + .tag(pane) + } + .navigationTitle("Settings") + .navigationSplitViewColumnWidth(min: 165, ideal: 185, max: 220) + } detail: { + detailView + .navigationTitle(selectedPane?.title ?? "Settings") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { await viewModel.saveSettings() } + } + .disabled(viewModel.isSavingSettings || selectedPane == .connection || selectedPane == .rules) + } + } + } + .frame(minWidth: 760, idealWidth: 860, minHeight: 540, idealHeight: 620) + .task { + if viewModel.settings.isEmpty { + await viewModel.loadSettings() + } + } + .overlay(alignment: .bottom) { + if let message = viewModel.statusMessage { + Text(message) + .font(.callout) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.regularMaterial, in: Capsule()) + .padding(.bottom, 12) + .task(id: message) { + try? await Task.sleep(for: .seconds(3)) + viewModel.clearStatusMessage() + } + } + } + } + + @ViewBuilder + private var detailView: some View { + switch selectedPane ?? .general { + case .connection: + ServerSettingsView(viewModel: viewModel) + case .rules: + RulesSettingsView(viewModel: viewModel) + case .advanced: + AllSettingsView(viewModel: viewModel) + case let pane: + DynamicSettingsForm(viewModel: viewModel, definitions: SettingsCatalog.definitions(for: pane)) + } + } +} + +private struct DynamicSettingsForm: View { + @ObservedObject var viewModel: AppViewModel + let definitions: [SettingDefinition] + + private var sections: [String] { + definitions.reduce(into: []) { result, definition in + if !result.contains(definition.section) { result.append(definition.section) } + } + } + + var body: some View { + Form { + ForEach(sections, id: \.self) { section in + Section(section) { + ForEach(definitions.filter { $0.section == section }) { definition in + settingControl(definition) + } + } + } + + if definitions.contains(where: { $0.pane == .summaryAI }) { + Section("AI usage") { + if let usage = viewModel.aiUsage { + LabeledContent("Tokens", value: "\(usage.usage) / \(usage.limit)") + ProgressView(value: Double(usage.usage), total: Double(max(usage.limit, 1))) + Button("Reset usage counter") { + Task { await viewModel.resetAIUsage() } + } + } else { + Text("Usage information is unavailable.") + .foregroundStyle(.secondary) + } + } + + Section("Generated content") { + Button("Clear cached translations and summaries", role: .destructive) { + Task { await viewModel.clearGeneratedContent() } + } + } + } + } + .formStyle(.grouped) + .overlay { + if viewModel.isLoadingSettings { + ProgressView("Loading settings") + } + } + } + + @ViewBuilder + private func settingControl(_ definition: SettingDefinition) -> some View { + switch definition.control { + case .toggle: + Toggle(definition.title, isOn: boolBinding(definition.key)) + case .text: + LabeledContent(definition.title) { + TextField(definition.title, text: textBinding(definition.key)) + .multilineTextAlignment(.trailing) + .frame(minWidth: 240) + } + case .secure: + LabeledContent(definition.title) { + SecureField(definition.title, text: textBinding(definition.key)) + .multilineTextAlignment(.trailing) + .frame(minWidth: 240) + } + case .picker(let options): + Picker(definition.title, selection: textBinding(definition.key)) { + ForEach(options, id: \.0) { value, label in + Text(label).tag(value) + } + } + } + + if let detail = definition.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func textBinding(_ key: String) -> Binding { + Binding( + get: { viewModel.setting(key) }, + set: { viewModel.updateSetting(key, value: $0) } + ) + } + + private func boolBinding(_ key: String) -> Binding { + Binding( + get: { viewModel.boolSetting(key) }, + set: { viewModel.updateBoolSetting(key, value: $0) } + ) + } +} + +private struct AllSettingsView: View { + @ObservedObject var viewModel: AppViewModel + @State private var searchText = "" + + private var keys: [String] { + viewModel.settings.keys + .filter { searchText.isEmpty || $0.localizedCaseInsensitiveContains(searchText) } + .sorted() + } + + var body: some View { + Form { + Section { + Text("This pane exposes every backend setting, including internal state. Use the categorized panes for normal configuration.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Backend settings") { + ForEach(keys, id: \.self) { key in + if SettingsCatalog.boolKeys.contains(key) { + Toggle(key, isOn: Binding( + get: { viewModel.boolSetting(key) }, + set: { viewModel.updateBoolSetting(key, value: $0) } + )) + } else if SettingsCatalog.secureKeys.contains(key) { + LabeledContent(key) { + SecureField(key, text: valueBinding(key)) + .multilineTextAlignment(.trailing) + .frame(minWidth: 300) + } + } else { + LabeledContent(key) { + TextField(key, text: valueBinding(key), axis: .vertical) + .multilineTextAlignment(.trailing) + .lineLimit(1...5) + .frame(minWidth: 300) + } + } + } + } + } + .formStyle(.grouped) + .searchable(text: $searchText, prompt: "Filter setting keys") + } + + private func valueBinding(_ key: String) -> Binding { + Binding( + get: { viewModel.setting(key) }, + set: { viewModel.updateSetting(key, value: $0) } + ) + } +} diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift new file mode 100644 index 00000000..7af6d1e6 --- /dev/null +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +struct SidebarView: View { + @ObservedObject var viewModel: AppViewModel + @State private var isShowingAddFeed = false + @State private var feedPendingDeletion: Feed? + + var body: some View { + VStack(spacing: 0) { + List { + Section("Library") { + ForEach(ArticleFilter.allCases) { filter in + sidebarButton(for: .filter(filter)) { + Label(filter.title, systemImage: filter.icon) + } + .badge(badge(for: filter)) + } + } + + Section("Feeds") { + if viewModel.isLoadingFeeds && viewModel.feeds.isEmpty { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading feeds") + .foregroundStyle(.secondary) + } + } else if viewModel.feeds.isEmpty { + Text("No feeds available") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.feeds) { feed in + sidebarButton(for: .feed(feed.id)) { + FeedLabel(feed: feed) + } + .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) + .contextMenu { + Button("Delete Feed", systemImage: "trash", role: .destructive) { + feedPendingDeletion = feed + } + } + } + } + } + } + .listStyle(.sidebar) + + Divider() + + HStack(spacing: 8) { + Circle() + .fill(viewModel.connectionState.color) + .frame(width: 7, height: 7) + Text(viewModel.connectionState.title) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if viewModel.isLoadingFeeds || viewModel.isRefreshingSources { + ProgressView() + .controlSize(.mini) + } + } + .padding(.horizontal, 12) + .frame(height: 36) + .background(.ultraThinMaterial) + } + .navigationTitle("MrRSS") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button("Add feed", systemImage: "plus") { + isShowingAddFeed = true + } + .help("Add a feed") + + Button("Refresh feeds", systemImage: "arrow.clockwise") { + viewModel.refreshFromSources() + } + .disabled(viewModel.isRefreshingSources) + .help("Refresh feeds and articles") + } + } + .sheet(isPresented: $isShowingAddFeed) { + AddFeedView(viewModel: viewModel) + } + .confirmationDialog( + "Delete \(feedPendingDeletion?.title ?? "this feed")?", + isPresented: Binding( + get: { feedPendingDeletion != nil }, + set: { if !$0 { feedPendingDeletion = nil } } + ) + ) { + Button("Delete Feed", role: .destructive) { + guard let feed = feedPendingDeletion else { return } + feedPendingDeletion = nil + Task { await viewModel.deleteFeed(feed) } + } + Button("Cancel", role: .cancel) { + feedPendingDeletion = nil + } + } message: { + Text("The subscription and its stored articles will be removed.") + } + } + + private func badge(for filter: ArticleFilter) -> Int { + switch filter { + case .all, .unread: + viewModel.unreadCounts.total + case .favorites, .readLater: + 0 + } + } + + private func sidebarButton( + for item: SidebarItem, + @ViewBuilder label: () -> Label + ) -> some View { + Button { + viewModel.selection = item + } label: { + label() + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground( + viewModel.selection == item + ? Color.accentColor.opacity(0.16) + : Color.clear + ) + } +} + +private struct FeedLabel: View { + let feed: Feed + + var body: some View { + Label { + Text(feed.title) + .lineLimit(1) + } icon: { + if let iconURL = feed.iconURL, + let url = URL(string: iconURL) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Image(systemName: "dot.radiowaves.left.and.right") + } + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } else { + Image(systemName: "dot.radiowaves.left.and.right") + } + } + } +} diff --git a/frontend-swift/Sources/Views/WebView.swift b/frontend-swift/Sources/Views/WebView.swift new file mode 100644 index 00000000..81a47c49 --- /dev/null +++ b/frontend-swift/Sources/Views/WebView.swift @@ -0,0 +1,222 @@ +import AppKit +import SwiftUI +import WebKit + +struct WebView: NSViewRepresentable { + let html: String + let baseURL: URL? + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> ClippedWebViewContainer { + let configuration = WKWebViewConfiguration() + let webpagePreferences = WKWebpagePreferences() + webpagePreferences.allowsContentJavaScript = false + configuration.defaultWebpagePreferences = webpagePreferences + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + configuration.websiteDataStore = .nonPersistent() + configuration.mediaTypesRequiringUserActionForPlayback = .all + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.underPageBackgroundColor = .textBackgroundColor + return ClippedWebViewContainer(webView: webView) + } + + func updateNSView(_ container: ClippedWebViewContainer, context: Context) { + let webView = container.webView + let document = HTMLDocument.build(from: html) + guard document != context.coordinator.loadedDocument || baseURL != context.coordinator.loadedBaseURL else { + return + } + + context.coordinator.loadedDocument = document + context.coordinator.loadedBaseURL = baseURL + webView.loadHTMLString(document, baseURL: baseURL) + } + + /// The container never reports a size of its own. Web content can be far + /// taller than the window, and any size derived from the loaded document + /// would be propagated up to `NavigationSplitView`, which then lays the + /// whole window out around that height. + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: ClippedWebViewContainer, + context: Context + ) -> CGSize? { + CGSize( + width: WebView.resolvedLength(proposal.width), + height: WebView.resolvedLength(proposal.height) + ) + } + + /// Maps an unspecified or infinite proposal to zero so the surrounding + /// `frame(maxWidth:maxHeight:)` decides how much space the view receives. + private static func resolvedLength(_ value: CGFloat?) -> CGFloat { + guard let value, value.isFinite, value > 0 else { return 0 } + return value + } + + final class Coordinator: NSObject, WKNavigationDelegate { + var loadedDocument = "" + var loadedBaseURL: URL? + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if navigationAction.navigationType == .linkActivated { + if ["http", "https"].contains(url.scheme?.lowercased() ?? "") { + NSWorkspace.shared.open(url) + } + decisionHandler(.cancel) + return + } + + if url.scheme == "about" || navigationAction.targetFrame == nil { + decisionHandler(.allow) + } else if navigationAction.targetFrame?.isMainFrame == true, + url == loadedBaseURL { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + } +} + +final class ClippedWebViewContainer: NSView { + let webView: WKWebView + + init(webView: WKWebView) { + self.webView = webView + super.init(frame: .zero) + + wantsLayer = true + layer?.masksToBounds = true + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentHuggingPriority(.defaultLow, for: .vertical) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + webView.frame = bounds + webView.autoresizingMask = [.width, .height] + webView.wantsLayer = true + webView.layer?.masksToBounds = true + addSubview(webView) + } + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + + override func layout() { + super.layout() + webView.frame = bounds + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +enum HTMLDocument { + static func build(from input: String) -> String { + let sanitized = sanitize(input) + return """ + + + + + + + + + \(sanitized) + + """ + } + + static func escape(_ input: String) -> String { + input + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + private static func sanitize(_ input: String) -> String { + var output = input + let patterns = [ + #"(?is)<(script|iframe|object|embed|form|base)\b[^>]*>.*?"#, + #"(?is)<(script|iframe|object|embed|form|base|meta)\b[^>]*?/?>"#, + #"(?i)\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)"#, + #"(?i)(href|src)\s*=\s*([\"'])\s*javascript:.*?\2"# + ] + + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + let range = NSRange(output.startIndex..., in: output) + output = regex.stringByReplacingMatches(in: output, range: range, withTemplate: "") + } + return output + } +} diff --git a/frontend-swift/Tests/APIServiceTests.swift b/frontend-swift/Tests/APIServiceTests.swift new file mode 100644 index 00000000..7eba5bc1 --- /dev/null +++ b/frontend-swift/Tests/APIServiceTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import MrRSS + +final class APIServiceTests: XCTestCase { + override func tearDown() { + MockURLProtocol.handler = nil + super.tearDown() + } + + func testFetchArticlesSendsAllFilterAndPagination() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: configuration) + let service = APIService(baseURL: URL(string: "http://127.0.0.1:1234/api")!, session: session) + + MockURLProtocol.handler = { request in + let components = try XCTUnwrap(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + XCTAssertEqual(components.path, "/api/articles") + XCTAssertEqual(query["filter"], "all") + XCTAssertEqual(query["page"], "2") + XCTAssertEqual(query["limit"], "25") + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("[]".utf8)) + } + + let articles = try await service.fetchArticles( + feedID: nil, + category: nil, + filter: "all", + page: 2, + limit: 25 + ) + + XCTAssertTrue(articles.isEmpty) + } + + func testServerErrorIncludesResponseMessage() async { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: configuration) + let service = APIService(baseURL: URL(string: "http://127.0.0.1:1234/api")!, session: session) + + MockURLProtocol.handler = { request in + return ( + HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil)!, + Data("database unavailable".utf8) + ) + } + + do { + _ = try await service.fetchFeeds() + XCTFail("Expected the request to fail") + } catch let error as APIError { + XCTAssertEqual(error, .server(statusCode: 503, message: "database unavailable")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testServerAddressNormalizationAddsSchemeAndAPIPath() { + let url = ServerConfiguration.normalizedURL(from: "127.0.0.1:1234") + XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:1234/api") + } + + func testUpdateSettingsSendsCompleteJSONMap() async throws { + let service = makeService() + MockURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/settings") + let body = try Self.requestBody(from: request) + let values = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: String]) + XCTAssertEqual(values["theme"], "dark") + XCTAssertEqual(values["translation_enabled"], "true") + return Self.response(for: request, data: Data()) + } + + try await service.updateSettings(["theme": "dark", "translation_enabled": "true"]) + } + + func testTranslationSummaryAndRuleResponsesDecode() async throws { + let service = makeService() + MockURLProtocol.handler = { request in + switch request.url?.path { + case "/api/articles/translate": + return Self.response( + for: request, + data: Data(#"{"translated_title":"译文","limit_reached":false}"#.utf8) + ) + case "/api/articles/summarize": + return Self.response( + for: request, + data: Data(#"{"summary":"摘要","is_too_short":false}"#.utf8) + ) + case "/api/rules/apply": + return Self.response( + for: request, + data: Data(#"{"success":true,"affected":4}"#.utf8) + ) + default: + XCTFail("Unexpected endpoint: \(request.url?.path ?? "nil")") + return Self.response(for: request, statusCode: 404, data: Data()) + } + } + + let translation = try await service.translateTitle(articleID: 1, title: "Text", targetLanguage: "zh") + let summary = try await service.summarize(articleID: 1, length: "medium", content: "Body") + let ruleResult = try await service.applyRule( + AutomationRule(id: 1, name: "Test", enabled: true, conditions: [], actions: ["favorite"]) + ) + + XCTAssertEqual(translation.translatedTitle, "译文") + XCTAssertEqual(summary.summary, "摘要") + XCTAssertNil(summary.sentenceCount) + XCTAssertEqual(ruleResult.affected, 4) + } + + private func makeService() -> APIService { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return APIService( + baseURL: URL(string: "http://127.0.0.1:1234/api")!, + session: URLSession(configuration: configuration) + ) + } + + private static func response( + for request: URLRequest, + statusCode: Int = 200, + data: Data + ) -> (HTTPURLResponse, Data) { + ( + HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!, + data + ) + } + + private static func requestBody(from request: URLRequest) throws -> Data { + if let body = request.httpBody { return body } + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { throw stream.streamError ?? APIError.invalidResponse } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } +} + +private final class MockURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + XCTFail("MockURLProtocol handler was not configured") + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift new file mode 100644 index 00000000..7e2467e6 --- /dev/null +++ b/frontend-swift/Tests/AppViewModelTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import MrRSS + +@MainActor +final class AppViewModelTests: XCTestCase { + func testChangingSelectionDiscardsPreviousResponse() async throws { + let client = DelayedAPIClient() + let viewModel = AppViewModel(api: client, autoLoad: false) + + viewModel.reloadArticles() + try await Task.sleep(for: .milliseconds(20)) + viewModel.selection = .feed(2) + try await Task.sleep(for: .milliseconds(300)) + + XCTAssertEqual(viewModel.articles.map(\.id), [2]) + } + + func testReadActionCanMarkArticleUnread() async throws { + let client = DelayedAPIClient() + client.defaultArticles = [Self.article(id: 1, feedID: 1, isRead: true)] + let viewModel = AppViewModel(api: client, autoLoad: false) + + viewModel.reloadArticles() + try await Task.sleep(for: .milliseconds(220)) + let article = try XCTUnwrap(viewModel.articles.first) + viewModel.setArticleRead(article, read: false) + + XCTAssertEqual(viewModel.articles.first?.isRead, false) + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(client.lastReadMutation, .init(id: 1, read: false)) + } + + func testReloadKeepsCurrentArticlesUntilReplacementArrives() async throws { + let client = DelayedAPIClient() + let viewModel = AppViewModel(api: client, autoLoad: false) + + viewModel.reloadArticles() + try await Task.sleep(for: .milliseconds(220)) + XCTAssertEqual(viewModel.articles.map(\.id), [1]) + + client.defaultArticles = [Self.article(id: 3, feedID: 1)] + viewModel.reloadArticles() + + XCTAssertEqual(viewModel.articles.map(\.id), [1]) + try await Task.sleep(for: .milliseconds(220)) + XCTAssertEqual(viewModel.articles.map(\.id), [3]) + } + + func testSelectingAndMarkingArticleReadKeepsItInTheList() async throws { + let client = DelayedAPIClient() + client.defaultArticles = [Self.article(id: 1, feedID: 1)] + let viewModel = AppViewModel(api: client, autoLoad: false) + + viewModel.reloadArticles() + try await Task.sleep(for: .milliseconds(220)) + let article = try XCTUnwrap(viewModel.articles.first) + + viewModel.selectArticle(article) + viewModel.setArticleRead(article, read: true) + + XCTAssertEqual(viewModel.selectedArticleID, 1) + XCTAssertEqual(viewModel.articles.map(\.id), [1]) + XCTAssertEqual(viewModel.articles.first?.isRead, true) + } + + private static func article(id: Int, feedID: Int, isRead: Bool = false) -> Article { + Article( + id: id, + feedID: feedID, + feedTitle: "Feed \(feedID)", + title: "Article \(id)", + url: "https://example.com/\(id)", + publishedAt: "2026-08-16T08:00:00Z", + isRead: isRead + ) + } +} + +final class DelayedAPIClient: APIClient { + struct ReadMutation: Equatable { + let id: Int + let read: Bool + } + + let baseURL = URL(string: "http://127.0.0.1:1234/api")! + var defaultArticles = [ + Article( + id: 1, + feedID: 1, + feedTitle: "Feed 1", + title: "Old article", + url: "https://example.com/1", + publishedAt: "2026-08-16T08:00:00Z" + ) + ] + private(set) var lastReadMutation: ReadMutation? + var articleContent = ArticleContent(content: "", feedURL: nil) + + func checkConnection() async throws {} + func fetchFeeds() async throws -> [Feed] { [] } + func fetchUnreadCounts() async throws -> UnreadCounts { .empty } + func addFeed(url: String, title: String, category: String) async throws {} + func deleteFeed(id: Int) async throws {} + func refreshAllFeeds() async throws {} + func fetchRefreshProgress() async throws -> RefreshProgress { + RefreshProgress(isRunning: false) + } + + func fetchArticles( + feedID: Int?, + category: String?, + filter: String, + page: Int, + limit: Int + ) async throws -> [Article] { + if feedID == 2 { + try await Task.sleep(for: .milliseconds(10)) + return [ + Article( + id: 2, + feedID: 2, + feedTitle: "Feed 2", + title: "Current article", + url: "https://example.com/2", + publishedAt: "2026-08-16T08:00:00Z" + ) + ] + } + + try? await Task.sleep(for: .milliseconds(180)) + return defaultArticles + } + + func setArticleRead(id: Int, read: Bool) async throws { + lastReadMutation = ReadMutation(id: id, read: read) + } + + func toggleFavorite(id: Int) async throws {} + + func fetchArticleContent(id: Int) async throws -> ArticleContent { + articleContent + } + + func fetchSettings() async throws -> [String: String] { [:] } + func updateSettings(_ settings: [String: String]) async throws {} + func translateTitle( + articleID: Int, + title: String, + targetLanguage: String + ) async throws -> TitleTranslationResponse { + TitleTranslationResponse(translatedTitle: title, limitReached: false) + } + func translateText(_ text: String, targetLanguage: String) async throws -> TextTranslationResponse { + TextTranslationResponse(translatedText: text, html: text) + } + func summarize(articleID: Int, length: String, content: String?) async throws -> SummaryResult { + SummaryResult( + summary: "Summary", + html: nil, + sentenceCount: 1, + isTooShort: false, + limitReached: false, + usedFallback: false, + thinking: nil, + error: nil, + cached: false + ) + } + func clearTranslations() async throws {} + func clearSummaries() async throws {} + func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult { + RuleApplicationResult(success: true, affected: 0) + } + func fetchAIUsage() async throws -> AIUsage { + AIUsage(usage: 0, limit: 20_000, limitReached: false) + } + func resetAIUsage() async throws {} +} diff --git a/frontend-swift/Tests/HTMLDocumentTests.swift b/frontend-swift/Tests/HTMLDocumentTests.swift new file mode 100644 index 00000000..343f1449 --- /dev/null +++ b/frontend-swift/Tests/HTMLDocumentTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import MrRSS + +final class HTMLDocumentTests: XCTestCase { + func testDocumentRemovesExecutableMarkup() { + let input = #"

Safe

Bad link"# + + let document = HTMLDocument.build(from: input) + + XCTAssertTrue(document.contains("

Safe

")) + XCTAssertFalse(document.contains("")) + XCTAssertFalse(document.contains("onclick=")) + XCTAssertFalse(document.contains("javascript:")) + XCTAssertTrue(document.contains("script-src 'none'")) + XCTAssertTrue(document.contains("form-action 'none'")) + } + + func testEscapeProtectsPlainTextSummary() { + XCTAssertEqual( + HTMLDocument.escape(#" & "world""#), + "<hello> & "world"" + ) + } +} diff --git a/frontend-swift/Tests/ModelsTests.swift b/frontend-swift/Tests/ModelsTests.swift new file mode 100644 index 00000000..c3c8dfd2 --- /dev/null +++ b/frontend-swift/Tests/ModelsTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import MrRSS + +final class ModelsTests: XCTestCase { + func testFeedDecodesBackendResponse() throws { + let data = Data(#""" + { + "id": 7, + "title": "Example", + "url": "https://example.com/feed.xml", + "category": "Technology", + "image_url": "", + "last_updated": "2026-08-16T08:00:00Z", + "description": "Additional backend fields are ignored" + } + """#.utf8) + + let feed = try JSONDecoder().decode(Feed.self, from: data) + + XCTAssertEqual(feed.id, 7) + XCTAssertEqual(feed.lastUpdated, "2026-08-16T08:00:00Z") + XCTAssertNil(feed.iconURL) + } + + func testFeedUsesSafeDefaultsForOlderResponses() throws { + let data = Data(#"{"id":1,"url":"https://example.com/rss","title":"Example"}"#.utf8) + + let feed = try JSONDecoder().decode(Feed.self, from: data) + + XCTAssertEqual(feed.category, "") + XCTAssertEqual(feed.lastUpdated, "") + } + + func testUnreadCountsDecodeIntegerFeedIdentifiers() throws { + let data = Data(#"{"total":5,"feed_counts":{"1":2,"42":3}}"#.utf8) + + let counts = try JSONDecoder().decode(UnreadCounts.self, from: data) + + XCTAssertEqual(counts, UnreadCounts(total: 5, feedCounts: [1: 2, 42: 3])) + } +} diff --git a/frontend-swift/Tests/WebViewLayoutTests.swift b/frontend-swift/Tests/WebViewLayoutTests.swift new file mode 100644 index 00000000..deb898a2 --- /dev/null +++ b/frontend-swift/Tests/WebViewLayoutTests.swift @@ -0,0 +1,145 @@ +import SwiftUI +import WebKit +import XCTest +@testable import MrRSS + +@MainActor +final class WebViewLayoutTests: XCTestCase { + func testContainerKeepsWebViewInsideItsBounds() { + let webView = WKWebView(frame: .zero) + let container = ClippedWebViewContainer(webView: webView) + container.frame = NSRect(x: 0, y: 0, width: 640, height: 480) + + container.layoutSubtreeIfNeeded() + + XCTAssertEqual(webView.frame, container.bounds) + XCTAssertEqual(container.intrinsicContentSize.width, NSView.noIntrinsicMetric) + XCTAssertEqual(container.intrinsicContentSize.height, NSView.noIntrinsicMetric) + XCTAssertTrue(container.layer?.masksToBounds == true) + XCTAssertTrue(webView.layer?.masksToBounds == true) + } + + func testArticleContentDoesNotExpandSplitViewBeyondWindow() async throws { + let client = DelayedAPIClient() + client.articleContent = ArticleContent( + content: String(repeating: "

Long article content.

", count: 2_000), + feedURL: "https://example.com" + ) + client.defaultArticles = [ + Article( + id: 1, + feedID: 1, + feedTitle: "Feed 1", + title: String(repeating: "Long article title ", count: 20), + url: "https://example.com/1", + publishedAt: "2026-08-16T08:00:00Z", + summary: String(repeating: "Long article summary. ", count: 400) + ) + ] + let viewModel = AppViewModel(api: client, autoLoad: false) + + viewModel.reloadArticles() + try await Task.sleep(for: .milliseconds(220)) + let article = try XCTUnwrap(viewModel.articles.first) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_280, height: 780), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView(rootView: ContentView(viewModel: viewModel)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + + viewModel.selectArticle(article) + try await Task.sleep(for: .milliseconds(600)) + window.layoutIfNeeded() + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(400)) + + let splitView = try XCTUnwrap(firstDescendant(of: NSSplitView.self, in: hostingView)) + XCTAssertEqual(splitView.frame.height, hostingView.bounds.height, accuracy: 1) + XCTAssertEqual(splitView.frame.width, hostingView.bounds.width, accuracy: 1) + + // The split view is hosted by an intermediate AppKit view. When a column + // reports an ideal height taller than the window, that view keeps its + // oversized height and is centred, which scrolls the sidebar and the + // article list out of the window. + let splitHost = try XCTUnwrap(splitView.superview?.superview) + XCTAssertEqual(splitHost.frame.origin.y, 0, accuracy: 1) + XCTAssertEqual(splitHost.frame.height, hostingView.bounds.height, accuracy: 1) + + let visibleBounds = hostingView.bounds + for column in splitView.subviews where column.frame.height > 100 { + let columnFrame = column.convert(column.bounds, to: hostingView) + XCTAssertEqual( + columnFrame.origin.y, 0, accuracy: 1, + "A split view column starts outside the window: \(columnFrame)" + ) + XCTAssertEqual( + columnFrame.height, visibleBounds.height, accuracy: 1, + "A split view column is taller than the window: \(columnFrame)" + ) + } + } + + func testDetailPaneReportsASizeIndependentOfItsContent() async throws { + let client = DelayedAPIClient() + client.articleContent = ArticleContent( + content: String(repeating: "

Long article content.

", count: 2_000), + feedURL: "https://example.com" + ) + let article = Article( + id: 1, + feedID: 1, + feedTitle: "Feed 1", + title: String(repeating: "Long article title ", count: 20), + url: "https://example.com/1", + publishedAt: "2026-08-16T08:00:00Z", + summary: String(repeating: "Long article summary. ", count: 400) + ) + let viewModel = AppViewModel(api: client, autoLoad: false) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView( + rootView: ArticleDetailView(article: article, viewModel: viewModel) + ) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(600)) + hostingView.layoutSubtreeIfNeeded() + + // A pane whose reported size follows the article text makes + // NavigationSplitView lay the window out around that size. + XCTAssertLessThanOrEqual( + hostingView.fittingSize.width, + hostingView.bounds.width, + "The detail pane asks for more width than it is given: \(hostingView.fittingSize)" + ) + XCTAssertLessThanOrEqual( + hostingView.fittingSize.height, + hostingView.bounds.height, + "The detail pane asks for more height than it is given: \(hostingView.fittingSize)" + ) + } + + private func firstDescendant(of type: View.Type, in root: NSView) -> View? { + if let match = root as? View { + return match + } + for subview in root.subviews { + if let match = firstDescendant(of: type, in: subview) { + return match + } + } + return nil + } +} diff --git a/frontend-swift/build-app.sh b/frontend-swift/build-app.sh new file mode 100755 index 00000000..9566f785 --- /dev/null +++ b/frontend-swift/build-app.sh @@ -0,0 +1,90 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +PROJECT_DIR="${SCRIPT_DIR:h}" +VERSION="${1:-dev}" +DIST_DIR="${SCRIPT_DIR}/dist" +APP_DIR="${DIST_DIR}/MrRSS-SwiftUI.app" +CONTENTS_DIR="${APP_DIR}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +SWIFT_BUILD_DIR="${SCRIPT_DIR}/.build/release-universal" +BACKEND_BUILD_DIR="${SCRIPT_DIR}/.build/backend-universal" +CLANG_CACHE_DIR="${SCRIPT_DIR}/.build/clang-module-cache" +DMG_PATH="${DIST_DIR}/MrRSS-${VERSION}-macos-swiftui-universal.dmg" +MRRSS_ARCH_STRING="${MRRSS_BUILD_ARCHS:-arm64 x86_64}" +MRRSS_ARCHES=("${(@s: :)MRRSS_ARCH_STRING}") +SWIFT_ARCH_ARGUMENTS=() +MRRSS_BUNDLE_VERSION="${VERSION%%-*}" + +for MRRSS_ARCH in "${MRRSS_ARCHES[@]}"; do + SWIFT_ARCH_ARGUMENTS+=(--arch "${MRRSS_ARCH}") +done + +if [[ ! -d "${PROJECT_DIR}/frontend/dist" ]]; then + echo "frontend/dist is required because the Go backend embeds those files." >&2 + echo "Run npm ci and npm run build in frontend first." >&2 + exit 1 +fi + +mkdir -p "${DIST_DIR}" "${SWIFT_BUILD_DIR}" "${BACKEND_BUILD_DIR}" "${CLANG_CACHE_DIR}" +rm -rf "${APP_DIR}" "${DMG_PATH}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" + +echo "Building the universal SwiftUI executable..." +cd "${SCRIPT_DIR}" +export CLANG_MODULE_CACHE_PATH="${CLANG_CACHE_DIR}" +swift build \ + --disable-sandbox \ + -c release \ + "${SWIFT_ARCH_ARGUMENTS[@]}" \ + --scratch-path "${SWIFT_BUILD_DIR}" +cp "${SWIFT_BUILD_DIR}/apple/Products/Release/MrRSS" "${MACOS_DIR}/MrRSS-SwiftUI" + +echo "Building the Go backend for ${MRRSS_ARCH_STRING}..." +cd "${PROJECT_DIR}" +MRRSS_BACKEND_BINARIES=() +for MRRSS_ARCH in "${MRRSS_ARCHES[@]}"; do + if [[ "${MRRSS_ARCH}" == "x86_64" ]]; then + MRRSS_GO_ARCH="amd64" + else + MRRSS_GO_ARCH="${MRRSS_ARCH}" + fi + MRRSS_BACKEND_PATH="${BACKEND_BUILD_DIR}/mrrss-server-${MRRSS_ARCH}" + CGO_ENABLED=0 GOOS=darwin GOARCH="${MRRSS_GO_ARCH}" go build \ + -tags server \ + -trimpath \ + -ldflags="-s -w" \ + -o "${MRRSS_BACKEND_PATH}" . + MRRSS_BACKEND_BINARIES+=("${MRRSS_BACKEND_PATH}") +done + +if (( ${#MRRSS_BACKEND_BINARIES[@]} == 1 )); then + cp "${MRRSS_BACKEND_BINARIES[1]}" "${RESOURCES_DIR}/mrrss-server" +else + lipo -create "${MRRSS_BACKEND_BINARIES[@]}" -output "${RESOURCES_DIR}/mrrss-server" +fi +chmod +x "${MACOS_DIR}/MrRSS-SwiftUI" "${RESOURCES_DIR}/mrrss-server" + +cp "${PROJECT_DIR}/build/darwin/icons.icns" "${RESOURCES_DIR}/AppIcon.icns" + +sed \ + -e "s/@VERSION@/${VERSION}/g" \ + -e "s/@BUILD_VERSION@/${MRRSS_BUNDLE_VERSION}/g" \ + "${SCRIPT_DIR}/packaging/Info.plist" > "${CONTENTS_DIR}/Info.plist" + +codesign --force --sign - "${RESOURCES_DIR}/mrrss-server" +codesign --force --deep --sign - "${APP_DIR}" + +echo "Creating ${DMG_PATH:t}..." +hdiutil create \ + -volname "MrRSS SwiftUI" \ + -srcfolder "${APP_DIR}" \ + -ov \ + -format UDZO \ + "${DMG_PATH}" + +echo "Created ${APP_DIR}" +echo "Created ${DMG_PATH}" diff --git a/frontend-swift/packaging/Info.plist b/frontend-swift/packaging/Info.plist new file mode 100644 index 00000000..f83e05e0 --- /dev/null +++ b/frontend-swift/packaging/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + MrRSS-SwiftUI + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.devxdojo.mrrss.swiftui + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MrRSS + CFBundleDisplayName + MrRSS + CFBundlePackageType + APPL + CFBundleShortVersionString + @VERSION@ + CFBundleVersion + @BUILD_VERSION@ + LSApplicationCategoryType + public.app-category.news + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/frontend-swift/run.sh b/frontend-swift/run.sh new file mode 100755 index 00000000..03418d7c --- /dev/null +++ b/frontend-swift/run.sh @@ -0,0 +1,49 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +PROJECT_DIR="${SCRIPT_DIR:h}" +BACKEND_DIR="${SCRIPT_DIR}/.build/backend" +BACKEND_BIN="${BACKEND_DIR}/mrrss-server" +SERVER_URL="http://127.0.0.1:1234/api/version" +BACKEND_PID="" + +cleanup() { + if [[ -n "${BACKEND_PID}" ]]; then + kill "${BACKEND_PID}" 2>/dev/null || true + wait "${BACKEND_PID}" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +if ! curl --silent --fail --max-time 1 "${SERVER_URL}" >/dev/null 2>&1; then + mkdir -p "${BACKEND_DIR}" + echo "Building the MrRSS backend..." + (cd "${PROJECT_DIR}" && go build -tags server -o "${BACKEND_BIN}" .) + + echo "Starting the MrRSS backend on 127.0.0.1:1234..." + "${BACKEND_BIN}" -host 127.0.0.1 -port 1234 & + BACKEND_PID=$! + + for _ in {1..100}; do + if curl --silent --fail --max-time 1 "${SERVER_URL}" >/dev/null 2>&1; then + break + fi + if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then + echo "The MrRSS backend exited before it became ready." >&2 + exit 1 + fi + sleep 0.1 + done + + if ! curl --silent --fail --max-time 1 "${SERVER_URL}" >/dev/null 2>&1; then + echo "The MrRSS backend did not become ready within 10 seconds." >&2 + exit 1 + fi +fi + +echo "Starting the MrRSS SwiftUI frontend..." +cd "${SCRIPT_DIR}" +swift run MrRSS From eede0b812b7f436c1163e6c8c04e3af3206d8153 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 20:05:58 +0800 Subject: [PATCH 08/59] fix(translation): make the free Google provider more reliable --- internal/translation/dynamic.go | 4 +- internal/translation/google.go | 100 +++++++++++++++++- .../translation/translation_providers_test.go | 36 +++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/internal/translation/dynamic.go b/internal/translation/dynamic.go index 0651f743..c667f998 100644 --- a/internal/translation/dynamic.go +++ b/internal/translation/dynamic.go @@ -121,7 +121,7 @@ func (t *DynamicTranslator) getTranslatorWithProvider() (Translator, string, err var translator Translator switch provider { case "google": - translator = NewGoogleFreeTranslator() + translator = NewGoogleFreeTranslatorWithDB(t.settings) case "deepl": // For deeplx self-hosted, endpoint is required but API key is optional if endpoint == "" && apiKey == "" { @@ -151,7 +151,7 @@ func (t *DynamicTranslator) getTranslatorWithProvider() (Translator, string, err } translator = aiTranslator default: - translator = NewGoogleFreeTranslator() + translator = NewGoogleFreeTranslatorWithDB(t.settings) } // Cache the translator diff --git a/internal/translation/google.go b/internal/translation/google.go index 0ec15ce6..956df6f6 100644 --- a/internal/translation/google.go +++ b/internal/translation/google.go @@ -5,7 +5,9 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" + "unicode" ) type GoogleFreeTranslator struct { @@ -39,6 +41,31 @@ func (t *GoogleFreeTranslator) Translate(text, targetLang string) (string, error if text == "" { return "", nil } + if textAlreadyMatchesTarget(text, targetLang) { + return text, nil + } + + translated, err := t.translateWithGoogle(text, targetLang) + if err != nil { + return "", fmt.Errorf("google translation service is unavailable: %s", sanitizedTranslationError(err)) + } + return translated, nil +} + +func (t *GoogleFreeTranslator) translateWithGoogle(text, targetLang string) (string, error) { + chunks := splitTranslationText(text, 1_800) + translatedChunks := make([]string, 0, len(chunks)) + for _, chunk := range chunks { + translated, err := t.translateGoogleChunk(chunk, targetLang) + if err != nil { + return "", err + } + translatedChunks = append(translatedChunks, translated) + } + return strings.Join(translatedChunks, ""), nil +} + +func (t *GoogleFreeTranslator) translateGoogleChunk(text, targetLang string) (string, error) { // Get the configured endpoint, default to translate.googleapis.com endpoint := "translate.googleapis.com" @@ -74,7 +101,12 @@ func (t *GoogleFreeTranslator) Translate(text, targetLang string) (string, error q.Set("q", text) u.RawQuery = q.Encode() - resp, err := t.client.Get(u.String()) + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + resp, err := t.client.Do(req) if err != nil { return "", err } @@ -109,3 +141,69 @@ func (t *GoogleFreeTranslator) Translate(text, targetLang string) (string, error return "", fmt.Errorf("invalid response format") } + +func textAlreadyMatchesTarget(text, targetLang string) bool { + var letters, cjk, latin int + for _, r := range text { + if unicode.IsLetter(r) { + letters++ + if unicode.In(r, unicode.Han) { + cjk++ + } else if unicode.In(r, unicode.Latin) { + latin++ + } + } + } + if letters == 0 { + return true + } + switch strings.ToLower(targetLang) { + case "zh", "zh-cn", "zh-tw", "zh-hans", "zh-hant": + return cjk*100/letters >= 70 + case "en", "en-us", "en-gb": + return latin*100/letters >= 95 + default: + return false + } +} + +func splitTranslationText(text string, maxBytes int) []string { + if len([]byte(text)) <= maxBytes { + return []string{text} + } + + chunks := make([]string, 0, len(text)/maxBytes+1) + var builder strings.Builder + currentBytes := 0 + for _, r := range text { + runeBytes := len(string(r)) + if currentBytes > 0 && currentBytes+runeBytes > maxBytes { + chunks = append(chunks, builder.String()) + builder.Reset() + currentBytes = 0 + } + builder.WriteRune(r) + currentBytes += runeBytes + } + if builder.Len() > 0 { + chunks = append(chunks, builder.String()) + } + return chunks +} + +func sanitizedTranslationError(err error) string { + if err == nil { + return "unknown error" + } + message := err.Error() + if strings.Contains(message, "context deadline exceeded") || strings.Contains(message, "Client.Timeout") { + return "request timed out; configure the proxy or select another translation provider" + } + if strings.Contains(message, "no such host") { + return "service address could not be resolved; configure the proxy or select another translation provider" + } + if len(message) > 160 { + return message[:160] + } + return message +} diff --git a/internal/translation/translation_providers_test.go b/internal/translation/translation_providers_test.go index 5a0b5805..5a4da617 100644 --- a/internal/translation/translation_providers_test.go +++ b/internal/translation/translation_providers_test.go @@ -1,6 +1,7 @@ package translation import ( + "context" "io" "net/http" "strings" @@ -109,3 +110,38 @@ func TestAITranslate_AutoDetectOllama(t *testing.T) { t.Fatalf("expected 2 API calls (OpenAI then Ollama), got %d", callCount) } } + +func TestGoogleTranslateSkipsRequestWhenTextMatchesTargetLanguage(t *testing.T) { + translator := NewGoogleFreeTranslator() + requestCount := 0 + translator.client = &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) { + requestCount++ + return nil, context.DeadlineExceeded + })} + + translated, err := translator.Translate("这里是中文内容", "zh") + if err != nil { + t.Fatalf("expected same-language translation to succeed: %v", err) + } + if translated != "这里是中文内容" { + t.Fatalf("expected unchanged text, got %q", translated) + } + if requestCount != 0 { + t.Fatalf("expected no network requests, got %d", requestCount) + } +} + +func TestGoogleTranslateReturnsSanitizedTimeout(t *testing.T) { + translator := NewGoogleFreeTranslator() + translator.client = &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + })} + + _, err := translator.Translate("Hello", "zh") + if err == nil { + t.Fatal("expected translation to fail") + } + if strings.Contains(err.Error(), "q=Hello") || !strings.Contains(err.Error(), "configure the proxy") { + t.Fatalf("expected a concise configuration hint without source text, got %q", err.Error()) + } +} From b519a1be5680b5c8bebc96e01b534e7e662af427 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 20:36:55 +0800 Subject: [PATCH 09/59] fix(macos): show the app in the Dock and enable the Edit shortcuts --- frontend-swift/Sources/MrRSSApp.swift | 2 +- ...BackendManager.swift => AppDelegate.swift} | 37 ++++++++++++++++++- frontend-swift/Tests/AppDelegateTests.swift | 19 ++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) rename frontend-swift/Sources/Services/{BackendManager.swift => AppDelegate.swift} (52%) create mode 100644 frontend-swift/Tests/AppDelegateTests.swift diff --git a/frontend-swift/Sources/MrRSSApp.swift b/frontend-swift/Sources/MrRSSApp.swift index de61b60a..19ed8345 100644 --- a/frontend-swift/Sources/MrRSSApp.swift +++ b/frontend-swift/Sources/MrRSSApp.swift @@ -2,7 +2,7 @@ import SwiftUI @main struct MrRSSApp: App { - @NSApplicationDelegateAdaptor(BackendManager.self) private var backendManager + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var viewModel = AppViewModel(autoLoad: false) var body: some Scene { diff --git a/frontend-swift/Sources/Services/BackendManager.swift b/frontend-swift/Sources/Services/AppDelegate.swift similarity index 52% rename from frontend-swift/Sources/Services/BackendManager.swift rename to frontend-swift/Sources/Services/AppDelegate.swift index 11d6d552..eb61dff8 100644 --- a/frontend-swift/Sources/Services/BackendManager.swift +++ b/frontend-swift/Sources/Services/AppDelegate.swift @@ -1,10 +1,11 @@ import AppKit import Foundation -final class BackendManager: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate { private var process: Process? func applicationDidFinishLaunching(_ notification: Notification) { + presentAsForegroundApplication() startBundledBackendIfNeeded() } @@ -12,6 +13,40 @@ final class BackendManager: NSObject, NSApplicationDelegate { stopBundledBackend() } + /// A SwiftPM executable is not an application bundle, so AppKit launches it + /// with the `.prohibited` activation policy. Such a process has no Dock icon + /// and never owns the menu bar, which also leaves the standard Edit + /// shortcuts such as Command-V and Command-A without any effect inside text + /// fields. The packaged application already launches as a regular + /// application and is left untouched. + private func presentAsForegroundApplication() { + guard NSApp.activationPolicy() != .regular else { return } + + NSApp.setActivationPolicy(.regular) + if let iconURL = AppDelegate.repositoryIconURL(startingAt: Bundle.main.bundleURL), + let icon = NSImage(contentsOf: iconURL) { + NSApp.applicationIconImage = icon + } + NSApp.activate() + } + + /// Looks for the repository icon by walking up from the executable, so an + /// unbundled run shows the real icon instead of a generic placeholder. + static func repositoryIconURL(startingAt directory: URL) -> URL? { + var current = directory + for _ in 0..<8 { + let candidate = current.appendingPathComponent("build/darwin/icons.icns") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + + let parent = current.deletingLastPathComponent() + guard parent.path != current.path else { return nil } + current = parent + } + return nil + } + private func startBundledBackendIfNeeded() { let baseURL = ServerConfiguration.savedBaseURL guard ["127.0.0.1", "localhost"].contains(baseURL.host?.lowercased() ?? ""), diff --git a/frontend-swift/Tests/AppDelegateTests.swift b/frontend-swift/Tests/AppDelegateTests.swift new file mode 100644 index 00000000..eb3c8248 --- /dev/null +++ b/frontend-swift/Tests/AppDelegateTests.swift @@ -0,0 +1,19 @@ +import AppKit +import XCTest +@testable import MrRSS + +final class AppDelegateTests: XCTestCase { + func testRepositoryIconIsFoundFromTheBuildDirectory() throws { + let url = try XCTUnwrap( + AppDelegate.repositoryIconURL(startingAt: Bundle(for: type(of: self)).bundleURL), + "The application icon should be reachable from the built executable." + ) + + XCTAssertEqual(url.lastPathComponent, "icons.icns") + XCTAssertNotNil(NSImage(contentsOf: url)) + } + + func testRepositoryIconLookupStopsAtTheFilesystemRoot() { + XCTAssertNil(AppDelegate.repositoryIconURL(startingAt: URL(fileURLWithPath: "/"))) + } +} From 983fd64e44e4015aa7c2ae0c963c337f7ae6a2f9 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 20:40:16 +0800 Subject: [PATCH 10/59] fix(macos): give sidebar rows a visible selection --- .../Sources/Views/SidebarView.swift | 43 ++++-------- frontend-swift/Tests/AppViewModelTests.swift | 3 +- .../Tests/SidebarSelectionTests.swift | 66 +++++++++++++++++++ 3 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 frontend-swift/Tests/SidebarSelectionTests.swift diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index 7af6d1e6..6b29e081 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -7,13 +7,12 @@ struct SidebarView: View { var body: some View { VStack(spacing: 0) { - List { + List(selection: $viewModel.selection) { Section("Library") { ForEach(ArticleFilter.allCases) { filter in - sidebarButton(for: .filter(filter)) { - Label(filter.title, systemImage: filter.icon) - } - .badge(badge(for: filter)) + Label(filter.title, systemImage: filter.icon) + .badge(badge(for: filter)) + .tag(SidebarItem.filter(filter)) } } @@ -31,15 +30,14 @@ struct SidebarView: View { .foregroundStyle(.secondary) } else { ForEach(viewModel.feeds) { feed in - sidebarButton(for: .feed(feed.id)) { - FeedLabel(feed: feed) - } - .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) - .contextMenu { - Button("Delete Feed", systemImage: "trash", role: .destructive) { - feedPendingDeletion = feed + FeedLabel(feed: feed) + .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) + .tag(SidebarItem.feed(feed.id)) + .contextMenu { + Button("Delete Feed", systemImage: "trash", role: .destructive) { + feedPendingDeletion = feed + } } - } } } } @@ -111,25 +109,6 @@ struct SidebarView: View { 0 } } - - private func sidebarButton( - for item: SidebarItem, - @ViewBuilder label: () -> Label - ) -> some View { - Button { - viewModel.selection = item - } label: { - label() - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .listRowBackground( - viewModel.selection == item - ? Color.accentColor.opacity(0.16) - : Color.clear - ) - } } private struct FeedLabel: View { diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift index 7e2467e6..25455048 100644 --- a/frontend-swift/Tests/AppViewModelTests.swift +++ b/frontend-swift/Tests/AppViewModelTests.swift @@ -95,9 +95,10 @@ final class DelayedAPIClient: APIClient { ] private(set) var lastReadMutation: ReadMutation? var articleContent = ArticleContent(content: "", feedURL: nil) + var defaultFeeds: [Feed] = [] func checkConnection() async throws {} - func fetchFeeds() async throws -> [Feed] { [] } + func fetchFeeds() async throws -> [Feed] { defaultFeeds } func fetchUnreadCounts() async throws -> UnreadCounts { .empty } func addFeed(url: String, title: String, category: String) async throws {} func deleteFeed(id: Int) async throws {} diff --git a/frontend-swift/Tests/SidebarSelectionTests.swift b/frontend-swift/Tests/SidebarSelectionTests.swift new file mode 100644 index 00000000..4662e427 --- /dev/null +++ b/frontend-swift/Tests/SidebarSelectionTests.swift @@ -0,0 +1,66 @@ +import AppKit +import SwiftUI +import XCTest +@testable import MrRSS + +@MainActor +final class SidebarSelectionTests: XCTestCase { + func testSidebarMarksTheCurrentItemAsSelected() async throws { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://example.com/1", title: "阮一峰的网络日志", category: ""), + Feed(id: 2, url: "https://example.com/2", title: "V2EX", category: "") + ] + let viewModel = AppViewModel(api: client, autoLoad: false) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + XCTAssertEqual(viewModel.feeds.count, 2) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 600), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(500)) + + let tableView = try XCTUnwrap( + firstDescendant(of: NSTableView.self, in: hostingView), + "The sidebar should be backed by a table view." + ) + + // The default selection is the All Articles filter. + let defaultSelection = tableView.selectedRowIndexes + XCTAssertFalse( + defaultSelection.isEmpty, + "Clicking a sidebar row must leave the row visibly selected." + ) + + viewModel.selection = .feed(2) + try await Task.sleep(for: .milliseconds(400)) + hostingView.layoutSubtreeIfNeeded() + + XCTAssertFalse(tableView.selectedRowIndexes.isEmpty) + XCTAssertNotEqual( + tableView.selectedRowIndexes, + defaultSelection, + "Selecting another sidebar item must move the selection." + ) + } + + private func firstDescendant(of type: View.Type, in root: NSView) -> View? { + if let match = root as? View { + return match + } + for subview in root.subviews { + if let match = firstDescendant(of: type, in: subview) { + return match + } + } + return nil + } +} From d47d2fa95b87e5f6bc5292e5fc4ca0e34da69bba Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 20:54:55 +0800 Subject: [PATCH 11/59] fix(macos): lay out the Add Feed sheet without scrolling or crowding --- .../Sources/Views/AddFeedView.swift | 25 +++++++--- frontend-swift/Tests/AddFeedLayoutTests.swift | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 frontend-swift/Tests/AddFeedLayoutTests.swift diff --git a/frontend-swift/Sources/Views/AddFeedView.swift b/frontend-swift/Sources/Views/AddFeedView.swift index 943c6860..f049d032 100644 --- a/frontend-swift/Sources/Views/AddFeedView.swift +++ b/frontend-swift/Sources/Views/AddFeedView.swift @@ -18,12 +18,14 @@ struct AddFeedView: View { .foregroundStyle(.secondary) } - Form { - TextField("Feed URL", text: $url, prompt: Text("https://example.com/feed.xml")) - TextField("Title (optional)", text: $title) - TextField("Category (optional)", text: $category) + // The label sits above the field rather than beside it, so a long + // address keeps the full width of the sheet instead of scrolling + // out of a narrow field next to its label. + VStack(alignment: .leading, spacing: 14) { + field("Feed URL", text: $url, prompt: "https://example.com/feed.xml") + field("Title (optional)", text: $title, prompt: "Feed title") + field("Category (optional)", text: $category, prompt: "Category name") } - .formStyle(.grouped) HStack { Spacer() @@ -38,7 +40,7 @@ struct AddFeedView: View { } } .padding(24) - .frame(width: 500, height: 300) + .frame(width: 520) .overlay { if isSubmitting { ZStack { @@ -51,6 +53,17 @@ struct AddFeedView: View { } } + private func field(_ label: String, text: Binding, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + TextField(label, text: text, prompt: Text(prompt)) + .textFieldStyle(.roundedBorder) + .labelsHidden() + } + } + private func submit() { isSubmitting = true Task { diff --git a/frontend-swift/Tests/AddFeedLayoutTests.swift b/frontend-swift/Tests/AddFeedLayoutTests.swift new file mode 100644 index 00000000..7b5c49fa --- /dev/null +++ b/frontend-swift/Tests/AddFeedLayoutTests.swift @@ -0,0 +1,50 @@ +import AppKit +import SwiftUI +import XCTest +@testable import MrRSS + +@MainActor +final class AddFeedLayoutTests: XCTestCase { + func testSheetShowsEveryFieldAtFullWidthWithoutScrolling() async throws { + let viewModel = AppViewModel(api: DelayedAPIClient(), autoLoad: false) + let hostingView = NSHostingView(rootView: AddFeedView(viewModel: viewModel)) + let contentSize = hostingView.fittingSize + hostingView.frame = NSRect(origin: .zero, size: contentSize) + + let window = NSWindow( + contentRect: hostingView.frame, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(300)) + + XCTAssertTrue( + descendants(of: NSScrollView.self, in: hostingView).isEmpty, + "A three field sheet should size itself to its content instead of scrolling." + ) + + let fields = descendants(of: NSTextField.self, in: hostingView).filter(\.isEditable) + XCTAssertEqual(fields.count, 3) + + // Each field spans the sheet minus its padding, so a long address never + // runs into a label placed beside it. + for field in fields { + XCTAssertEqual(field.frame.width, contentSize.width - 48, accuracy: 1) + } + } + + private func descendants(of type: View.Type, in root: NSView) -> [View] { + var found: [View] = [] + if let match = root as? View { + found.append(match) + } + for subview in root.subviews { + found.append(contentsOf: descendants(of: type, in: subview)) + } + return found + } +} From c0f606832df5cc7c4e0ba8797a00743fcef91f19 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 21:17:20 +0800 Subject: [PATCH 12/59] feat(macos): group subscriptions into folders and colour their icons --- frontend-swift/README.md | 4 +- frontend-swift/Sources/Models/Models.swift | 2 +- .../Sources/Services/APIService.swift | 10 +- .../Sources/ViewModels/AppViewModel.swift | 145 ++++++++- .../Sources/Views/ArticleListView.swift | 2 + .../Sources/Views/FeedAccentColor.swift | 42 +++ .../Sources/Views/FolderNameView.swift | 64 ++++ .../Sources/Views/SidebarView.swift | 307 ++++++++++++++---- frontend-swift/Tests/AppViewModelTests.swift | 12 +- .../Tests/FeedAccentColorTests.swift | 48 +++ frontend-swift/Tests/FolderTests.swift | 145 +++++++++ .../handlers/feed/category_handlers_test.go | 68 ++++ internal/handlers/feed/feed_handlers.go | 33 ++ internal/routes/feed_routes.go | 3 + 14 files changed, 805 insertions(+), 80 deletions(-) create mode 100644 frontend-swift/Sources/Views/FeedAccentColor.swift create mode 100644 frontend-swift/Sources/Views/FolderNameView.swift create mode 100644 frontend-swift/Tests/FeedAccentColorTests.swift create mode 100644 frontend-swift/Tests/FolderTests.swift create mode 100644 internal/handlers/feed/category_handlers_test.go diff --git a/frontend-swift/README.md b/frontend-swift/README.md index f5ed806f..bd080662 100644 --- a/frontend-swift/README.md +++ b/frontend-swift/README.md @@ -58,4 +58,6 @@ cd ../frontend-swift The release application contains both the SwiftUI frontend and reusable Go backend, so it can launch without a separately installed server. -The application provides native three-column navigation, feed subscription management, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. +The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. + +Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. diff --git a/frontend-swift/Sources/Models/Models.swift b/frontend-swift/Sources/Models/Models.swift index 5e677ba8..5ebd3efb 100644 --- a/frontend-swift/Sources/Models/Models.swift +++ b/frontend-swift/Sources/Models/Models.swift @@ -4,7 +4,7 @@ struct Feed: Identifiable, Codable, Hashable { let id: Int let url: String let title: String - let category: String + var category: String let lastUpdated: String let iconURL: String? diff --git a/frontend-swift/Sources/Services/APIService.swift b/frontend-swift/Sources/Services/APIService.swift index 010f770e..cf067ffa 100644 --- a/frontend-swift/Sources/Services/APIService.swift +++ b/frontend-swift/Sources/Services/APIService.swift @@ -73,6 +73,7 @@ protocol APIClient: AnyObject { func fetchUnreadCounts() async throws -> UnreadCounts func addFeed(url: String, title: String, category: String) async throws func deleteFeed(id: Int) async throws + func updateFeedCategory(id: Int, category: String) async throws func refreshAllFeeds() async throws func fetchRefreshProgress() async throws -> RefreshProgress func fetchArticles( @@ -146,6 +147,13 @@ final class APIService: APIClient { ) } + func updateFeedCategory(id: Int, category: String) async throws { + try await post( + "feeds/category", + jsonBody: ["id": id, "category": category] + ) + } + func refreshAllFeeds() async throws { try await post("refresh") } @@ -309,7 +317,7 @@ final class APIService: APIClient { private func post( _ endpoint: String, queryItems: [URLQueryItem] = [], - jsonBody: [String: String]? = nil + jsonBody: [String: Any]? = nil ) async throws { let url = try makeURL(endpoint: endpoint, queryItems: queryItems) var request = URLRequest(url: url) diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift index 656feb44..e2e48094 100644 --- a/frontend-swift/Sources/ViewModels/AppViewModel.swift +++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift @@ -30,6 +30,7 @@ enum ArticleFilter: String, CaseIterable, Identifiable { enum SidebarItem: Hashable { case filter(ArticleFilter) + case folder(String) case feed(Int) } @@ -58,6 +59,7 @@ enum ConnectionState: Equatable { @MainActor final class AppViewModel: ObservableObject { @Published private(set) var feeds: [Feed] = [] + @Published private(set) var folders: [String] = [] @Published private(set) var articles: [Article] = [] @Published private(set) var unreadCounts = UnreadCounts.empty @Published private(set) var isLoadingArticles = false @@ -86,16 +88,24 @@ final class AppViewModel: ObservableObject { private var hasMore = true private let limit: Int private var api: APIClient + private let defaults: UserDefaults private var feedTask: Task? private var sourceRefreshTask: Task? private var feedRequestID = UUID() private var articleTask: Task? private var articleRequestID = UUID() - init(api: APIClient = APIService.shared, limit: Int = 50, autoLoad: Bool = true) { + init( + api: APIClient = APIService.shared, + limit: Int = 50, + autoLoad: Bool = true, + defaults: UserDefaults = .standard + ) { self.api = api self.limit = limit + self.defaults = defaults serverURLText = api.baseURL.absoluteString + refreshFolders() if autoLoad { refreshAll() @@ -143,6 +153,7 @@ final class AppViewModel: ObservableObject { try Task.checkCancellation() guard requestID == feedRequestID else { return } feeds = loadedFeeds + refreshFolders() unreadCounts = loadedCounts connectionState = .connected isLoadingFeeds = false @@ -222,6 +233,126 @@ final class AppViewModel: ObservableObject { } } + + // MARK: - Folders + + /// A folder is the category stored on each feed. A folder that somebody + /// created but has not filled yet has nowhere to live on the server, so its + /// name is remembered on this Mac until a feed moves into it. + private static let pendingFoldersKey = "MrRSS.pendingFolders" + + private var pendingFolders: Set { + get { Set(defaults.stringArray(forKey: Self.pendingFoldersKey) ?? []) } + set { defaults.set(newValue.sorted(), forKey: Self.pendingFoldersKey) } + } + + func feeds(inFolder folder: String) -> [Feed] { + feeds.filter { $0.category == folder } + } + + var unfiledFeeds: [Feed] { + feeds.filter { $0.category.isEmpty } + } + + func unreadCount(forFolder folder: String) -> Int { + feeds(inFolder: folder).reduce(0) { $0 + (unreadCounts.feedCounts[$1.id] ?? 0) } + } + + @discardableResult + func createFolder(named name: String) -> Bool { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + errorMessage = "Enter a folder name." + return false + } + guard !folders.contains(trimmed) else { + errorMessage = "A folder named \(trimmed) already exists." + return false + } + + pendingFolders.insert(trimmed) + refreshFolders() + return true + } + + func moveFeed(_ feed: Feed, toFolder folder: String?) async { + let target = (folder ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard feed.category != target else { return } + + do { + try await api.updateFeedCategory(id: feed.id, category: target) + } catch { + errorMessage = error.localizedDescription + return + } + + if let index = feeds.firstIndex(where: { $0.id == feed.id }) { + feeds[index].category = target + } + refreshFolders() + } + + func renameFolder(_ folder: String, to newName: String) async { + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != folder else { return } + guard !folders.contains(trimmed) else { + errorMessage = "A folder named \(trimmed) already exists." + return + } + + for feed in feeds(inFolder: folder) { + do { + try await api.updateFeedCategory(id: feed.id, category: trimmed) + } catch { + errorMessage = error.localizedDescription + refreshFeeds() + return + } + } + + for index in feeds.indices where feeds[index].category == folder { + feeds[index].category = trimmed + } + var stored = pendingFolders + stored.remove(folder) + stored.insert(trimmed) + pendingFolders = stored + if selection == .folder(folder) { + selection = .folder(trimmed) + } + refreshFolders() + } + + /// Removes the folder itself. The feeds it held stay subscribed and move + /// back out of any folder. + func deleteFolder(_ folder: String) async { + for feed in feeds(inFolder: folder) { + do { + try await api.updateFeedCategory(id: feed.id, category: "") + } catch { + errorMessage = error.localizedDescription + refreshFeeds() + return + } + } + + for index in feeds.indices where feeds[index].category == folder { + feeds[index].category = "" + } + pendingFolders.remove(folder) + if selection == .folder(folder) { + selection = .filter(.all) + } + refreshFolders() + } + + private func refreshFolders() { + let assigned = Set(feeds.map(\.category).filter { !$0.isEmpty }) + let stored = pendingFolders.subtracting(assigned) + pendingFolders = stored + folders = assigned.union(stored).sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + func reloadArticles() { articleTask?.cancel() articleRequestID = UUID() @@ -484,7 +615,7 @@ final class AppViewModel: ObservableObject { do { let loadedArticles = try await api.fetchArticles( feedID: query.feedID, - category: nil, + category: query.category, filter: query.filter, page: targetPage, limit: limit @@ -514,14 +645,16 @@ final class AppViewModel: ObservableObject { } } - private var articleQuery: (feedID: Int?, filter: String) { + private var articleQuery: (feedID: Int?, category: String?, filter: String) { switch selection { case .filter(let filter): - return (nil, filter.rawValue) + return (nil, nil, filter.rawValue) + case .folder(let name): + return (nil, name, "") case .feed(let id): - return (id, "") + return (id, nil, "") case .none: - return (nil, ArticleFilter.all.rawValue) + return (nil, nil, ArticleFilter.all.rawValue) } } } diff --git a/frontend-swift/Sources/Views/ArticleListView.swift b/frontend-swift/Sources/Views/ArticleListView.swift index 7e3da44d..ce5b8e03 100644 --- a/frontend-swift/Sources/Views/ArticleListView.swift +++ b/frontend-swift/Sources/Views/ArticleListView.swift @@ -82,6 +82,8 @@ struct ArticleListView: View { switch viewModel.selection { case .filter(let filter): filter.title + case .folder(let name): + name case .feed(let id): viewModel.feeds.first(where: { $0.id == id })?.title ?? "Feed" case .none: diff --git a/frontend-swift/Sources/Views/FeedAccentColor.swift b/frontend-swift/Sources/Views/FeedAccentColor.swift new file mode 100644 index 00000000..c4018132 --- /dev/null +++ b/frontend-swift/Sources/Views/FeedAccentColor.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// A stable colour for each subscription, so the sidebar reads as a list of +/// distinguishable sources rather than a column of identical glyphs. The hues +/// are spread around the wheel instead of being limited to the primaries. +enum FeedAccentColor { + static let palette: [Color] = [ + Color(red: 0.90, green: 0.26, blue: 0.28), // red + Color(red: 0.95, green: 0.48, blue: 0.16), // orange + Color(red: 0.93, green: 0.71, blue: 0.13), // amber + Color(red: 0.71, green: 0.75, blue: 0.16), // olive + Color(red: 0.30, green: 0.71, blue: 0.31), // green + Color(red: 0.16, green: 0.71, blue: 0.58), // teal + Color(red: 0.15, green: 0.68, blue: 0.80), // cyan + Color(red: 0.20, green: 0.51, blue: 0.89), // blue + Color(red: 0.33, green: 0.36, blue: 0.82), // indigo + Color(red: 0.55, green: 0.34, blue: 0.80), // violet + Color(red: 0.79, green: 0.31, blue: 0.68), // magenta + Color(red: 0.88, green: 0.36, blue: 0.48), // rose + Color(red: 0.58, green: 0.44, blue: 0.32), // brown + Color(red: 0.42, green: 0.53, blue: 0.60) // slate + ] + + static func color(for feed: Feed) -> Color { + palette[index(for: identity(of: feed))] + } + + static func index(for identity: String) -> Int { + // FNV-1a keeps the colour stable across launches, which Swift's own + // hashing does not guarantee because it is seeded per process. + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in identity.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x1000_0000_01b3 + } + return Int(hash % UInt64(palette.count)) + } + + private static func identity(of feed: Feed) -> String { + feed.url.isEmpty ? feed.title : feed.url + } +} diff --git a/frontend-swift/Sources/Views/FolderNameView.swift b/frontend-swift/Sources/Views/FolderNameView.swift new file mode 100644 index 00000000..f9c89051 --- /dev/null +++ b/frontend-swift/Sources/Views/FolderNameView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct FolderNameView: View { + let prompt: FolderPrompt + @ObservedObject var viewModel: AppViewModel + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var isSubmitting = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 5) { + Text(prompt.title) + .font(.title2.bold()) + Text(prompt.message) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 5) { + Text("Name") + .font(.subheadline) + .foregroundStyle(.secondary) + TextField("Name", text: $name, prompt: Text("Technology")) + .textFieldStyle(.roundedBorder) + .labelsHidden() + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { + dismiss() + } + Button(prompt.confirmTitle) { + submit() + } + .keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting) + } + } + .padding(24) + .frame(width: 420) + .onAppear { name = prompt.initialName } + } + + private func submit() { + isSubmitting = true + Task { + switch prompt { + case .create: + if viewModel.createFolder(named: name) { dismiss() } + case .createWithFeed(let feed): + if viewModel.createFolder(named: name) { + await viewModel.moveFeed(feed, toFolder: name) + dismiss() + } + case .rename(let folder): + await viewModel.renameFolder(folder, to: name) + dismiss() + } + isSubmitting = false + } + } +} diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index 6b29e081..a05f0bfc 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -4,83 +4,24 @@ struct SidebarView: View { @ObservedObject var viewModel: AppViewModel @State private var isShowingAddFeed = false @State private var feedPendingDeletion: Feed? + @State private var folderPendingDeletion: String? + @State private var expandedFolders: Set = [] + @State private var folderPrompt: FolderPrompt? var body: some View { VStack(spacing: 0) { - List(selection: $viewModel.selection) { - Section("Library") { - ForEach(ArticleFilter.allCases) { filter in - Label(filter.title, systemImage: filter.icon) - .badge(badge(for: filter)) - .tag(SidebarItem.filter(filter)) - } - } - - Section("Feeds") { - if viewModel.isLoadingFeeds && viewModel.feeds.isEmpty { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Loading feeds") - .foregroundStyle(.secondary) - } - } else if viewModel.feeds.isEmpty { - Text("No feeds available") - .font(.callout) - .foregroundStyle(.secondary) - } else { - ForEach(viewModel.feeds) { feed in - FeedLabel(feed: feed) - .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) - .tag(SidebarItem.feed(feed.id)) - .contextMenu { - Button("Delete Feed", systemImage: "trash", role: .destructive) { - feedPendingDeletion = feed - } - } - } - } - } - } - .listStyle(.sidebar) - + feedList Divider() - - HStack(spacing: 8) { - Circle() - .fill(viewModel.connectionState.color) - .frame(width: 7, height: 7) - Text(viewModel.connectionState.title) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if viewModel.isLoadingFeeds || viewModel.isRefreshingSources { - ProgressView() - .controlSize(.mini) - } - } - .padding(.horizontal, 12) - .frame(height: 36) - .background(.ultraThinMaterial) + statusBar } .navigationTitle("MrRSS") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button("Add feed", systemImage: "plus") { - isShowingAddFeed = true - } - .help("Add a feed") - - Button("Refresh feeds", systemImage: "arrow.clockwise") { - viewModel.refreshFromSources() - } - .disabled(viewModel.isRefreshingSources) - .help("Refresh feeds and articles") - } - } + .toolbar { toolbarContent } .sheet(isPresented: $isShowingAddFeed) { AddFeedView(viewModel: viewModel) } + .sheet(item: $folderPrompt) { prompt in + FolderNameView(prompt: prompt, viewModel: viewModel) + } .confirmationDialog( "Delete \(feedPendingDeletion?.title ?? "this feed")?", isPresented: Binding( @@ -99,6 +40,181 @@ struct SidebarView: View { } message: { Text("The subscription and its stored articles will be removed.") } + .confirmationDialog( + "Delete the folder \(folderPendingDeletion ?? "")?", + isPresented: Binding( + get: { folderPendingDeletion != nil }, + set: { if !$0 { folderPendingDeletion = nil } } + ) + ) { + Button("Delete Folder", role: .destructive) { + guard let folder = folderPendingDeletion else { return } + folderPendingDeletion = nil + Task { await viewModel.deleteFolder(folder) } + } + Button("Cancel", role: .cancel) { + folderPendingDeletion = nil + } + } message: { + Text("The feeds it holds stay subscribed and move back out of any folder.") + } + } + + private var feedList: some View { + List(selection: $viewModel.selection) { + Section("Library") { + ForEach(ArticleFilter.allCases) { filter in + Label(filter.title, systemImage: filter.icon) + .badge(badge(for: filter)) + .tag(SidebarItem.filter(filter)) + } + } + + Section("Feeds") { + feedSection + } + } + .listStyle(.sidebar) + } + + @ViewBuilder + private var feedSection: some View { + if viewModel.isLoadingFeeds && viewModel.feeds.isEmpty { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading feeds") + .foregroundStyle(.secondary) + } + } else if viewModel.feeds.isEmpty && viewModel.folders.isEmpty { + Text("No feeds available") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.folders, id: \.self) { folder in + folderRow(folder) + } + + ForEach(viewModel.unfiledFeeds) { feed in + feedRow(feed) + } + } + } + + private var statusBar: some View { + HStack(spacing: 8) { + Circle() + .fill(viewModel.connectionState.color) + .frame(width: 7, height: 7) + Text(viewModel.connectionState.title) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if viewModel.isLoadingFeeds || viewModel.isRefreshingSources { + ProgressView() + .controlSize(.mini) + } + } + .padding(.horizontal, 12) + .frame(height: 36) + .background(.ultraThinMaterial) + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .primaryAction) { + Menu { + Button("Add Feed…", systemImage: "plus") { + isShowingAddFeed = true + } + Button("New Folder…", systemImage: "folder.badge.plus") { + folderPrompt = .create + } + } label: { + Label("Add", systemImage: "plus") + } + .help("Add a feed or a folder") + + Button("Refresh feeds", systemImage: "arrow.clockwise") { + viewModel.refreshFromSources() + } + .disabled(viewModel.isRefreshingSources) + .help("Refresh feeds and articles") + } + } + + private func folderRow(_ folder: String) -> some View { + DisclosureGroup( + isExpanded: Binding( + get: { expandedFolders.contains(folder) }, + set: { isExpanded in + if isExpanded { + expandedFolders.insert(folder) + } else { + expandedFolders.remove(folder) + } + } + ) + ) { + ForEach(viewModel.feeds(inFolder: folder)) { feed in + feedRow(feed) + } + } label: { + Label { + Text(folder) + .lineLimit(1) + } icon: { + Image(systemName: expandedFolders.contains(folder) ? "folder.fill" : "folder") + .foregroundStyle(.tint) + } + .badge(viewModel.unreadCount(forFolder: folder)) + .tag(SidebarItem.folder(folder)) + .contextMenu { + Button("Rename Folder…", systemImage: "pencil") { + folderPrompt = .rename(folder) + } + Button("Delete Folder", systemImage: "trash", role: .destructive) { + folderPendingDeletion = folder + } + } + } + } + + private func feedRow(_ feed: Feed) -> some View { + FeedLabel(feed: feed) + .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) + .tag(SidebarItem.feed(feed.id)) + .contextMenu { + Menu("Move to Folder") { + ForEach(viewModel.folders, id: \.self) { folder in + Button(folder) { + Task { await viewModel.moveFeed(feed, toFolder: folder) } + } + .disabled(feed.category == folder) + } + + if !viewModel.folders.isEmpty { + Divider() + } + + Button("New Folder…") { + folderPrompt = .createWithFeed(feed) + } + + if !feed.category.isEmpty { + Divider() + Button("Remove from Folder") { + Task { await viewModel.moveFeed(feed, toFolder: nil) } + } + } + } + + Divider() + + Button("Delete Feed", systemImage: "trash", role: .destructive) { + feedPendingDeletion = feed + } + } } private func badge(for filter: ArticleFilter) -> Int { @@ -111,6 +227,52 @@ struct SidebarView: View { } } +enum FolderPrompt: Identifiable { + case create + case createWithFeed(Feed) + case rename(String) + + var id: String { + switch self { + case .create: "create" + case .createWithFeed(let feed): "create-\(feed.id)" + case .rename(let folder): "rename-\(folder)" + } + } + + var title: String { + switch self { + case .create, .createWithFeed: "New Folder" + case .rename: "Rename Folder" + } + } + + var message: String { + switch self { + case .create: + "Folders group your subscriptions in the sidebar." + case .createWithFeed(let feed): + "\(feed.title) will move into the new folder." + case .rename(let folder): + "Every feed in \(folder) moves to the new name." + } + } + + var initialName: String { + switch self { + case .create, .createWithFeed: "" + case .rename(let folder): folder + } + } + + var confirmTitle: String { + switch self { + case .create, .createWithFeed: "Create" + case .rename: "Rename" + } + } +} + private struct FeedLabel: View { let feed: Feed @@ -126,13 +288,18 @@ private struct FeedLabel: View { .resizable() .scaledToFill() } placeholder: { - Image(systemName: "dot.radiowaves.left.and.right") + fallbackIcon } .frame(width: 16, height: 16) .clipShape(RoundedRectangle(cornerRadius: 3)) } else { - Image(systemName: "dot.radiowaves.left.and.right") + fallbackIcon } } } + + private var fallbackIcon: some View { + Image(systemName: "dot.radiowaves.left.and.right") + .foregroundStyle(FeedAccentColor.color(for: feed)) + } } diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift index 25455048..b5e461a2 100644 --- a/frontend-swift/Tests/AppViewModelTests.swift +++ b/frontend-swift/Tests/AppViewModelTests.swift @@ -96,12 +96,21 @@ final class DelayedAPIClient: APIClient { private(set) var lastReadMutation: ReadMutation? var articleContent = ArticleContent(content: "", feedURL: nil) var defaultFeeds: [Feed] = [] + private(set) var categoryMutations: [(id: Int, category: String)] = [] + var categoryUpdateError: Error? + private(set) var lastArticleQuery: (feedID: Int?, category: String?, filter: String)? func checkConnection() async throws {} func fetchFeeds() async throws -> [Feed] { defaultFeeds } - func fetchUnreadCounts() async throws -> UnreadCounts { .empty } + var unreadCounts = UnreadCounts.empty + func fetchUnreadCounts() async throws -> UnreadCounts { unreadCounts } func addFeed(url: String, title: String, category: String) async throws {} func deleteFeed(id: Int) async throws {} + + func updateFeedCategory(id: Int, category: String) async throws { + if let categoryUpdateError { throw categoryUpdateError } + categoryMutations.append((id, category)) + } func refreshAllFeeds() async throws {} func fetchRefreshProgress() async throws -> RefreshProgress { RefreshProgress(isRunning: false) @@ -114,6 +123,7 @@ final class DelayedAPIClient: APIClient { page: Int, limit: Int ) async throws -> [Article] { + lastArticleQuery = (feedID, category, filter) if feedID == 2 { try await Task.sleep(for: .milliseconds(10)) return [ diff --git a/frontend-swift/Tests/FeedAccentColorTests.swift b/frontend-swift/Tests/FeedAccentColorTests.swift new file mode 100644 index 00000000..8dd1d227 --- /dev/null +++ b/frontend-swift/Tests/FeedAccentColorTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import XCTest +@testable import MrRSS + +final class FeedAccentColorTests: XCTestCase { + func testThePaletteOffersMoreThanTheThreePrimaries() { + XCTAssertGreaterThanOrEqual(FeedAccentColor.palette.count, 12) + XCTAssertEqual( + Set(FeedAccentColor.palette.map { String(describing: $0) }).count, + FeedAccentColor.palette.count, + "Every entry in the palette should be a distinct colour." + ) + } + + func testAFeedKeepsTheSameColourAcrossLookups() { + let feed = Feed(id: 7, url: "https://example.com/feed", title: "Example", category: "") + let first = FeedAccentColor.color(for: feed) + + // The colour is derived from the address, so renaming or renumbering a + // feed does not repaint it. + let renamed = Feed(id: 42, url: "https://example.com/feed", title: "Renamed", category: "Tech") + + XCTAssertEqual(first, FeedAccentColor.color(for: feed)) + XCTAssertEqual(first, FeedAccentColor.color(for: renamed)) + } + + func testTheHashIsStableAcrossProcessesAndStaysInRange() { + // A fixed expectation catches any switch to Swift's per-process hashing. + XCTAssertEqual(FeedAccentColor.index(for: "https://example.com/feed"), 6) + + for identity in (0..<200).map({ "https://example.com/\($0)" }) { + let index = FeedAccentColor.index(for: identity) + XCTAssertTrue((0.. AppViewModel { + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""), + Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "Tech"), + Feed(id: 3, url: "https://c.example.com/feed", title: "Feed C", category: "Tech") + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + return viewModel + } + + func testFoldersComeFromTheCategoryOnEachFeed() async throws { + let viewModel = try await makeViewModel(DelayedAPIClient()) + + XCTAssertEqual(viewModel.folders, ["Tech"]) + XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [2, 3]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1]) + } + + func testAnEmptyFolderSurvivesUntilAFeedMovesIntoIt() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + + XCTAssertTrue(viewModel.createFolder(named: " Reading ")) + XCTAssertEqual(viewModel.folders, ["Reading", "Tech"]) + + // A folder with no feeds has nothing to store on the server, so it is + // remembered locally and reappears on the next launch. + let reopened = AppViewModel(api: client, autoLoad: false, defaults: defaults) + XCTAssertEqual(reopened.folders, ["Reading"]) + } + + func testCreateFolderRejectsBlankAndDuplicateNames() async throws { + let viewModel = try await makeViewModel(DelayedAPIClient()) + + XCTAssertFalse(viewModel.createFolder(named: " ")) + XCTAssertNotNil(viewModel.errorMessage) + + viewModel.clearError() + XCTAssertFalse(viewModel.createFolder(named: "Tech")) + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertEqual(viewModel.folders, ["Tech"]) + } + + func testMovingAFeedUpdatesTheServerAndTheSidebar() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + + await viewModel.moveFeed(viewModel.feeds[0], toFolder: "Tech") + + XCTAssertEqual(client.categoryMutations.map(\.id), [1]) + XCTAssertEqual(client.categoryMutations.map(\.category), ["Tech"]) + XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 2, 3]) + XCTAssertTrue(viewModel.unfiledFeeds.isEmpty) + } + + func testMovingAFeedOutOfAFolderClearsItsCategory() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + let feed = try XCTUnwrap(viewModel.feeds.first(where: { $0.id == 2 })) + + await viewModel.moveFeed(feed, toFolder: nil) + + XCTAssertEqual(client.categoryMutations.map(\.category), [""]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 2]) + } + + func testAFailedMoveLeavesTheSidebarUnchanged() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + client.categoryUpdateError = URLError(.notConnectedToInternet) + + await viewModel.moveFeed(viewModel.feeds[0], toFolder: "Tech") + + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1]) + } + + func testRenamingAFolderMovesEveryFeedAndFollowsTheSelection() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + viewModel.selection = .folder("Tech") + + await viewModel.renameFolder("Tech", to: "Technology") + + XCTAssertEqual(client.categoryMutations.map(\.id), [2, 3]) + XCTAssertEqual(Set(client.categoryMutations.map(\.category)), ["Technology"]) + XCTAssertEqual(viewModel.folders, ["Technology"]) + XCTAssertEqual(viewModel.selection, .folder("Technology")) + } + + func testDeletingAFolderKeepsItsFeedsSubscribed() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + viewModel.selection = .folder("Tech") + + await viewModel.deleteFolder("Tech") + + XCTAssertEqual(Set(client.categoryMutations.map(\.category)), [""]) + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertEqual(viewModel.feeds.count, 3) + XCTAssertEqual(viewModel.selection, .filter(.all)) + } + + func testFolderBadgeSumsTheUnreadCountsOfItsFeeds() async throws { + let client = DelayedAPIClient() + client.unreadCounts = UnreadCounts(total: 30, feedCounts: [1: 5, 2: 7, 3: 18]) + let viewModel = try await makeViewModel(client) + + XCTAssertEqual(viewModel.unreadCount(forFolder: "Tech"), 25) + } + + func testSelectingAFolderAsksTheServerForThatCategory() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + + viewModel.selection = .folder("Tech") + try await Task.sleep(for: .milliseconds(300)) + + XCTAssertEqual(client.lastArticleQuery?.category, "Tech") + XCTAssertNil(client.lastArticleQuery?.feedID) + } +} diff --git a/internal/handlers/feed/category_handlers_test.go b/internal/handlers/feed/category_handlers_test.go new file mode 100644 index 00000000..27ddc496 --- /dev/null +++ b/internal/handlers/feed/category_handlers_test.go @@ -0,0 +1,68 @@ +package feed_test + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + fh "MrRSS/internal/handlers/feed" + "MrRSS/internal/models" +) + +// reuse setupHandler from feed_handlers_test.go + +func TestHandleUpdateFeedCategory(t *testing.T) { + h := setupHandler(t) + + id, err := h.DB.AddFeed(&models.Feed{ + Title: "example", + URL: "http://example.com/feed", + Category: "old", + ProxyURL: "http://127.0.0.1:8080", + }) + if err != nil { + t.Fatalf("AddFeed error: %v", err) + } + + body, _ := json.Marshal(map[string]any{"id": id, "category": " Tech "}) + req := httptest.NewRequest("POST", "/api/feeds/category", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + fh.HandleUpdateFeedCategory(h, w, req) + if w.Result().StatusCode != 200 { + t.Fatalf("expected 200 OK, got %d", w.Result().StatusCode) + } + + feed, err := h.DB.GetFeedByID(id) + if err != nil { + t.Fatalf("GetFeedByID error: %v", err) + } + if feed.Category != "Tech" { + t.Fatalf("expected the category to be trimmed to Tech, got %q", feed.Category) + } + // Moving a feed between categories must not disturb its other settings. + if feed.ProxyURL != "http://127.0.0.1:8080" { + t.Fatalf("expected the proxy setting to survive, got %q", feed.ProxyURL) + } +} + +func TestHandleUpdateFeedCategoryRejectsBadRequests(t *testing.T) { + h := setupHandler(t) + + req := httptest.NewRequest("POST", "/api/feeds/category", bytes.NewReader([]byte("notjson"))) + w := httptest.NewRecorder() + fh.HandleUpdateFeedCategory(h, w, req) + if w.Result().StatusCode != 400 { + t.Fatalf("expected 400 for an invalid payload, got %d", w.Result().StatusCode) + } + + body, _ := json.Marshal(map[string]any{"category": "Tech"}) + missingID := httptest.NewRequest("POST", "/api/feeds/category", bytes.NewReader(body)) + w2 := httptest.NewRecorder() + fh.HandleUpdateFeedCategory(h, w2, missingID) + if w2.Result().StatusCode != 400 { + t.Fatalf("expected 400 when the feed identifier is missing, got %d", w2.Result().StatusCode) + } +} diff --git a/internal/handlers/feed/feed_handlers.go b/internal/handlers/feed/feed_handlers.go index a2dc848b..8f8238c4 100644 --- a/internal/handlers/feed/feed_handlers.go +++ b/internal/handlers/feed/feed_handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" "MrRSS/internal/handlers/core" @@ -198,6 +199,38 @@ func HandleDeleteFeed(h *core.Handler, w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// HandleUpdateFeedCategory moves a feed into a category. +// @Summary Set a feed's category +// @Description Update only the category of a feed, leaving its other settings untouched +// @Tags feeds +// @Accept json +// @Produce json +// @Param request body object true "Feed identifier and category name" +// @Success 200 {string} string "Category updated successfully" +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /feeds/category [post] +func HandleUpdateFeedCategory(h *core.Handler, w http.ResponseWriter, r *http.Request) { + var req struct { + ID int64 `json:"id"` + Category string `json:"category"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, err, http.StatusBadRequest) + return + } + if req.ID <= 0 { + response.Error(w, nil, http.StatusBadRequest) + return + } + + if err := h.DB.UpdateFeedCategory(req.ID, strings.TrimSpace(req.Category)); err != nil { + response.Error(w, err, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // HandleUpdateFeed updates a feed's properties. // @Summary Update a feed // @Description Update properties of an existing feed subscription diff --git a/internal/routes/feed_routes.go b/internal/routes/feed_routes.go index 17c4cd23..9e78e6a1 100644 --- a/internal/routes/feed_routes.go +++ b/internal/routes/feed_routes.go @@ -18,6 +18,9 @@ func registerFeedRoutes(mux *http.ServeMux, h *core.Handler) { mux.HandleFunc("/api/feeds/add", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleAddFeed(h, w, r) }) mux.HandleFunc("/api/feeds/delete", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleDeleteFeed(h, w, r) }) mux.HandleFunc("/api/feeds/update", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleUpdateFeed(h, w, r) }) + mux.HandleFunc("/api/feeds/category", func(w http.ResponseWriter, r *http.Request) { + feedhandlers.HandleUpdateFeedCategory(h, w, r) + }) mux.HandleFunc("/api/feeds/refresh", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleRefreshFeed(h, w, r) }) mux.HandleFunc("/api/feeds/reorder", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleReorderFeed(h, w, r) }) mux.HandleFunc("/api/feeds/test-imap", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleTestIMAPConnection(h, w, r) }) From c87d8fbbb24fddc8740fdde94a2d441c4edd101a Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 21:39:21 +0800 Subject: [PATCH 13/59] feat(macos): create a folder from the empty area of the sidebar --- .../Sources/Views/SidebarBackgroundMenu.swift | 103 +++++++++++++++ .../Sources/Views/SidebarView.swift | 5 + .../Tests/SidebarBackgroundMenuTests.swift | 118 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 frontend-swift/Sources/Views/SidebarBackgroundMenu.swift create mode 100644 frontend-swift/Tests/SidebarBackgroundMenuTests.swift diff --git a/frontend-swift/Sources/Views/SidebarBackgroundMenu.swift b/frontend-swift/Sources/Views/SidebarBackgroundMenu.swift new file mode 100644 index 00000000..56e1f47d --- /dev/null +++ b/frontend-swift/Sources/Views/SidebarBackgroundMenu.swift @@ -0,0 +1,103 @@ +import AppKit +import SwiftUI + +/// Puts a menu on the sidebar's table view so a right click on the empty area +/// below the last row offers an action. SwiftUI's `contextMenu` reaches only +/// the rows themselves; the space beneath them belongs to the table view, which +/// has no SwiftUI representation. +struct SidebarBackgroundMenu: NSViewRepresentable { + let title: String + let action: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(title: title, action: action) + } + + func makeNSView(context: Context) -> NSView { + let view = MenuInstaller() + view.coordinator = context.coordinator + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.action = action + (nsView as? MenuInstaller)?.installMenu() + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSView, context: Context) -> CGSize? { + .zero + } + + final class Coordinator: NSObject { + let menu: NSMenu + var action: () -> Void + + init(title: String, action: @escaping () -> Void) { + self.action = action + menu = NSMenu() + super.init() + + let item = NSMenuItem(title: title, action: #selector(runAction), keyEquivalent: "") + item.target = self + menu.addItem(item) + } + + @objc private func runAction() { + action() + } + } + + final class MenuInstaller: NSView { + weak var coordinator: Coordinator? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installMenu() + } + + /// The table view is built while the list lays out, so the first attempt + /// can run before it exists. + func installMenu(remainingAttempts: Int = 5) { + guard let coordinator else { return } + + if let scrollView = nearestTableView()?.enclosingScrollView { + // The table view itself discards an assigned menu, and AppKit + // then looks for one on each ancestor in turn, so the clip view + // is the first place that can answer for the empty area. + scrollView.contentView.menu = coordinator.menu + scrollView.menu = coordinator.menu + return + } + + guard remainingAttempts > 0, window != nil else { return } + DispatchQueue.main.async { [weak self] in + self?.installMenu(remainingAttempts: remainingAttempts - 1) + } + } + + /// Searches outwards one ancestor at a time so the closest table view + /// wins, which is the sidebar's rather than another column's. + private func nearestTableView() -> NSTableView? { + var ancestor = superview + while let current = ancestor { + if let tableView = MenuInstaller.firstTableView(in: current) { + return tableView + } + ancestor = current.superview + } + return nil + } + + private static func firstTableView(in view: NSView) -> NSTableView? { + if let tableView = view as? NSTableView { + return tableView + } + for subview in view.subviews { + if let tableView = firstTableView(in: subview) { + return tableView + } + } + return nil + } + } +} diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index a05f0bfc..f8ae895c 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -75,6 +75,11 @@ struct SidebarView: View { } } .listStyle(.sidebar) + .background { + SidebarBackgroundMenu(title: "New Folder…") { + folderPrompt = .create + } + } } @ViewBuilder diff --git a/frontend-swift/Tests/SidebarBackgroundMenuTests.swift b/frontend-swift/Tests/SidebarBackgroundMenuTests.swift new file mode 100644 index 00000000..c83bc16f --- /dev/null +++ b/frontend-swift/Tests/SidebarBackgroundMenuTests.swift @@ -0,0 +1,118 @@ +import AppKit +import SwiftUI +import XCTest +@testable import MrRSS + +@MainActor +final class SidebarBackgroundMenuTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "MrRSSBackgroundMenuTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testRightClickBelowTheRowsOffersToCreateAFolder() async throws { + let (window, hostingView) = try await makeSidebar() + let tableView = try XCTUnwrap(firstDescendant(of: NSTableView.self, in: hostingView)) + + let lastRow = tableView.rect(ofRow: max(0, tableView.numberOfRows - 1)) + let emptyPoint = NSPoint(x: 100, y: lastRow.maxY + 60) + XCTAssertEqual(tableView.row(at: emptyPoint), -1, "The probe should land below the last row.") + + let titles = try XCTUnwrap(menuTitles(at: emptyPoint, in: tableView, window: window)) + XCTAssertTrue( + titles.contains("New Folder…"), + "The empty area of the sidebar should offer to create a folder, got \(titles)." + ) + } + + func testRightClickOnAFeedStillOffersTheFeedActions() async throws { + let (window, hostingView) = try await makeSidebar() + let tableView = try XCTUnwrap(firstDescendant(of: NSTableView.self, in: hostingView)) + + let feedRow = tableView.rect(ofRow: tableView.numberOfRows - 1) + let point = NSPoint(x: 100, y: feedRow.midY) + XCTAssertNotEqual(tableView.row(at: point), -1) + + let titles = try XCTUnwrap(menuTitles(at: point, in: tableView, window: window)) + XCTAssertTrue( + titles.contains("Delete Feed"), + "A row keeps its own menu instead of the background one, got \(titles)." + ) + XCTAssertFalse(titles.contains("New Folder…")) + } + + private func makeSidebar() async throws -> (NSWindow, NSHostingView) { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""), + Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "") + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 600), + styleMask: [.titled, .resizable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(500)) + return (window, hostingView) + } + + /// Mirrors how AppKit resolves a context menu: it hit tests the click and + /// then asks each ancestor in turn until one answers. + private func menuTitles(at point: NSPoint, in tableView: NSTableView, window: NSWindow) -> [String]? { + let windowPoint = tableView.convert(point, to: nil) + guard let event = NSEvent.mouseEvent( + with: .rightMouseDown, + location: windowPoint, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) else { + return nil + } + + var candidate = window.contentView?.hitTest(windowPoint) + while let view = candidate { + if let menu = view.menu(for: event) { + return menu.items.map(\.title) + } + candidate = view.superview + } + return nil + } + + private func firstDescendant(of type: View.Type, in root: NSView) -> View? { + if let match = root as? View { + return match + } + for subview in root.subviews { + if let match = firstDescendant(of: type, in: subview) { + return match + } + } + return nil + } +} From 5a65b0fd4c1e0e313c313674b6a569949c518794 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 21:46:34 +0800 Subject: [PATCH 14/59] feat(macos): drag a subscription onto a folder to file it --- frontend-swift/README.md | 2 +- .../Sources/Models/FeedTransfer.swift | 17 +++ .../Sources/ViewModels/AppViewModel.swift | 9 ++ .../Sources/Views/SidebarView.swift | 24 ++++ .../Tests/FeedDragAndDropTests.swift | 117 ++++++++++++++++++ frontend-swift/packaging/Info.plist | 15 +++ 6 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 frontend-swift/Sources/Models/FeedTransfer.swift create mode 100644 frontend-swift/Tests/FeedDragAndDropTests.swift diff --git a/frontend-swift/README.md b/frontend-swift/README.md index bd080662..6e0f1594 100644 --- a/frontend-swift/README.md +++ b/frontend-swift/README.md @@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. -Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. +Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, or from the row's context menu. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. diff --git a/frontend-swift/Sources/Models/FeedTransfer.swift b/frontend-swift/Sources/Models/FeedTransfer.swift new file mode 100644 index 00000000..ea596b27 --- /dev/null +++ b/frontend-swift/Sources/Models/FeedTransfer.swift @@ -0,0 +1,17 @@ +import SwiftUI +import UniformTypeIdentifiers + +extension UTType { + /// Private to MrRSS, so a drag coming from another application is never + /// mistaken for one of our subscriptions. + static let mrrssFeed = UTType(exportedAs: "com.devxdojo.mrrss.feed", conformingTo: .data) +} + +/// What a sidebar row carries while it is being dragged onto a folder. +struct FeedTransfer: Codable, Transferable, Equatable { + let feedID: Int + + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .mrrssFeed) + } +} diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift index e2e48094..bef6f255 100644 --- a/frontend-swift/Sources/ViewModels/AppViewModel.swift +++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift @@ -292,6 +292,15 @@ final class AppViewModel: ObservableObject { refreshFolders() } + /// Moves everything a drag carried. Identifiers that no longer match a + /// subscription are skipped rather than failing the whole drop. + func moveFeeds(ids: [Int], toFolder folder: String?) async { + for id in ids { + guard let feed = feeds.first(where: { $0.id == id }) else { continue } + await moveFeed(feed, toFolder: folder) + } + } + func renameFolder(_ folder: String, to newName: String) async { let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != folder else { return } diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index f8ae895c..851336c9 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -7,6 +7,7 @@ struct SidebarView: View { @State private var folderPendingDeletion: String? @State private var expandedFolders: Set = [] @State private var folderPrompt: FolderPrompt? + @State private var dropTargetFolder: String? var body: some View { VStack(spacing: 0) { @@ -174,6 +175,24 @@ struct SidebarView: View { } .badge(viewModel.unreadCount(forFolder: folder)) .tag(SidebarItem.folder(folder)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(dropTargetFolder == folder ? Color.accentColor.opacity(0.25) : .clear) + ) + .dropDestination(for: FeedTransfer.self) { transfers, _ in + guard !transfers.isEmpty else { return false } + Task { await viewModel.moveFeeds(ids: transfers.map(\.feedID), toFolder: folder) } + return true + } isTargeted: { isTargeted in + if isTargeted { + dropTargetFolder = folder + expandedFolders.insert(folder) + } else if dropTargetFolder == folder { + dropTargetFolder = nil + } + } .contextMenu { Button("Rename Folder…", systemImage: "pencil") { folderPrompt = .rename(folder) @@ -189,6 +208,11 @@ struct SidebarView: View { FeedLabel(feed: feed) .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) .tag(SidebarItem.feed(feed.id)) + .draggable(FeedTransfer(feedID: feed.id)) { + FeedLabel(feed: feed) + .padding(6) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) + } .contextMenu { Menu("Move to Folder") { ForEach(viewModel.folders, id: \.self) { folder in diff --git a/frontend-swift/Tests/FeedDragAndDropTests.swift b/frontend-swift/Tests/FeedDragAndDropTests.swift new file mode 100644 index 00000000..ac9881f5 --- /dev/null +++ b/frontend-swift/Tests/FeedDragAndDropTests.swift @@ -0,0 +1,117 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import XCTest +@testable import MrRSS + +@MainActor +final class FeedDragAndDropTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "MrRSSDragTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testTheDraggedPayloadUsesTheApplicationsOwnType() { + let provider = NSItemProvider() + provider.register(FeedTransfer(feedID: 1)) + + XCTAssertEqual(UTType.mrrssFeed.identifier, "com.devxdojo.mrrss.feed") + XCTAssertEqual( + provider.registeredTypeIdentifiers, + [UTType.mrrssFeed.identifier], + "A subscription travels under an identifier of our own, not as generic text." + ) + } + + func testTheDraggedPayloadSurvivesTheRoundTrip() async throws { + let provider = NSItemProvider() + provider.register(FeedTransfer(feedID: 42)) + + XCTAssertTrue(provider.registeredTypeIdentifiers.contains(UTType.mrrssFeed.identifier)) + + let restored: FeedTransfer = try await withCheckedThrowingContinuation { continuation in + _ = provider.loadTransferable(type: FeedTransfer.self) { result in + continuation.resume(with: result) + } + } + + XCTAssertEqual(restored, FeedTransfer(feedID: 42)) + } + + func testDroppingOnAFolderMovesEveryFeedInTheDrag() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + + await viewModel.moveFeeds(ids: [1, 3], toFolder: "Tech") + + XCTAssertEqual(client.categoryMutations.map(\.id), [1, 3]) + XCTAssertEqual(Set(client.categoryMutations.map(\.category)), ["Tech"]) + XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 2, 3]) + } + + func testDroppingAFeedThatNoLongerExistsIsIgnored() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + + await viewModel.moveFeeds(ids: [99, 1], toFolder: "Tech") + + XCTAssertEqual(client.categoryMutations.map(\.id), [1]) + XCTAssertNil(viewModel.errorMessage) + } + + func testTheSidebarAcceptsSubscriptionDrags() async throws { + let client = DelayedAPIClient() + let viewModel = try await makeViewModel(client) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 600), + styleMask: [.titled, .resizable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(500)) + + // Without a drop destination the sidebar registers only SwiftUI's own + // row reordering type. + let registered = registeredDraggedTypes(in: hostingView) + XCTAssertTrue( + registered.contains(NSPasteboard.PasteboardType(UTType.data.identifier)), + "A folder row should register as a drop target, got \(registered)." + ) + } + + private func makeViewModel(_ client: DelayedAPIClient) async throws -> AppViewModel { + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""), + Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "Tech"), + Feed(id: 3, url: "https://c.example.com/feed", title: "Feed C", category: "") + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + return viewModel + } + + private func registeredDraggedTypes(in root: NSView) -> Set { + var types = Set(root.registeredDraggedTypes) + for subview in root.subviews { + types.formUnion(registeredDraggedTypes(in: subview)) + } + return types + } +} diff --git a/frontend-swift/packaging/Info.plist b/frontend-swift/packaging/Info.plist index f83e05e0..51a9123b 100644 --- a/frontend-swift/packaging/Info.plist +++ b/frontend-swift/packaging/Info.plist @@ -30,6 +30,21 @@ NSPrincipalClass NSApplication + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.devxdojo.mrrss.feed + UTTypeDescription + MrRSS Subscription + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + + NSAppTransportSecurity NSAllowsLocalNetworking From 9df13da532045b8811cf2546c53b9d510d820dd7 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 21:57:20 +0800 Subject: [PATCH 15/59] fix(macos): make the drop half of folder filing work --- frontend-swift/README.md | 2 +- .../Sources/Models/FeedTransfer.swift | 55 +++++- .../Sources/Views/SidebarView.swift | 55 ++++-- .../Tests/FeedDragAndDropTests.swift | 32 ++-- .../Tests/FeedDropTargetTests.swift | 166 ++++++++++++++++++ frontend-swift/packaging/Info.plist | 15 -- 6 files changed, 275 insertions(+), 50 deletions(-) create mode 100644 frontend-swift/Tests/FeedDropTargetTests.swift diff --git a/frontend-swift/README.md b/frontend-swift/README.md index 6e0f1594..06a70862 100644 --- a/frontend-swift/README.md +++ b/frontend-swift/README.md @@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. -Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, or from the row's context menu. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. +Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. diff --git a/frontend-swift/Sources/Models/FeedTransfer.swift b/frontend-swift/Sources/Models/FeedTransfer.swift index ea596b27..dcab5683 100644 --- a/frontend-swift/Sources/Models/FeedTransfer.swift +++ b/frontend-swift/Sources/Models/FeedTransfer.swift @@ -2,16 +2,61 @@ import SwiftUI import UniformTypeIdentifiers extension UTType { - /// Private to MrRSS, so a drag coming from another application is never + /// Specific to MrRSS, so a drag coming from another application is never /// mistaken for one of our subscriptions. - static let mrrssFeed = UTType(exportedAs: "com.devxdojo.mrrss.feed", conformingTo: .data) + /// + /// The type is derived from a tag rather than declared with + /// `UTType(exportedAs:)`. An exported declaration only carries its + /// conformances when the bundle's Info.plist declares it as well, and a + /// type that conforms to nothing matches no drop target at all. + static let mrrssFeed = UTType( + tag: "mrrssfeed", + tagClass: .filenameExtension, + conformingTo: .data + ) ?? .data } /// What a sidebar row carries while it is being dragged onto a folder. -struct FeedTransfer: Codable, Transferable, Equatable { +/// +/// The payload travels under `UTType.mrrssFeed` alone. A drag registered under +/// a broad type such as `public.data` is claimed by the enclosing list, which +/// registers itself for row reordering, and never reaches the folder underneath +/// the pointer. +struct FeedTransfer: Codable, Equatable { let feedID: Int - static var transferRepresentation: some TransferRepresentation { - CodableRepresentation(contentType: .mrrssFeed) + var itemProvider: NSItemProvider { + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: UTType.mrrssFeed.identifier, + visibility: .ownProcess + ) { completion in + completion(try? JSONEncoder().encode(self), nil) + return nil + } + return provider + } + + static func feedIDs(from providers: [NSItemProvider]) async -> [Int] { + var ids: [Int] = [] + for provider in providers { + guard let transfer = await load(from: provider) else { continue } + ids.append(transfer.feedID) + } + return ids + } + + private static func load(from provider: NSItemProvider) async -> FeedTransfer? { + await withCheckedContinuation { continuation in + _ = provider.loadDataRepresentation( + forTypeIdentifier: UTType.mrrssFeed.identifier + ) { data, _ in + guard let data, let transfer = try? JSONDecoder().decode(FeedTransfer.self, from: data) else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: transfer) + } + } } } diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index 851336c9..95a315be 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers struct SidebarView: View { @ObservedObject var viewModel: AppViewModel @@ -8,6 +9,7 @@ struct SidebarView: View { @State private var expandedFolders: Set = [] @State private var folderPrompt: FolderPrompt? @State private var dropTargetFolder: String? + @State private var isDropTargetingRoot = false var body: some View { VStack(spacing: 0) { @@ -71,8 +73,20 @@ struct SidebarView: View { } } - Section("Feeds") { + Section { feedSection + } header: { + // Dropping on the section header takes a subscription back out + // of whatever folder it is in. + Text("Feeds") + .padding(.horizontal, 4) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isDropTargetingRoot ? Color.accentColor.opacity(0.25) : .clear) + ) + .onDrop(of: [.mrrssFeed], isTargeted: $isDropTargetingRoot) { providers in + move(providers, toFolder: nil) + } } } .listStyle(.sidebar) @@ -181,17 +195,21 @@ struct SidebarView: View { RoundedRectangle(cornerRadius: 5) .fill(dropTargetFolder == folder ? Color.accentColor.opacity(0.25) : .clear) ) - .dropDestination(for: FeedTransfer.self) { transfers, _ in - guard !transfers.isEmpty else { return false } - Task { await viewModel.moveFeeds(ids: transfers.map(\.feedID), toFolder: folder) } - return true - } isTargeted: { isTargeted in - if isTargeted { - dropTargetFolder = folder - expandedFolders.insert(folder) - } else if dropTargetFolder == folder { - dropTargetFolder = nil - } + .onDrop( + of: [.mrrssFeed], + isTargeted: Binding( + get: { dropTargetFolder == folder }, + set: { isTargeted in + if isTargeted { + dropTargetFolder = folder + expandedFolders.insert(folder) + } else if dropTargetFolder == folder { + dropTargetFolder = nil + } + } + ) + ) { providers in + move(providers, toFolder: folder) } .contextMenu { Button("Rename Folder…", systemImage: "pencil") { @@ -208,7 +226,9 @@ struct SidebarView: View { FeedLabel(feed: feed) .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) .tag(SidebarItem.feed(feed.id)) - .draggable(FeedTransfer(feedID: feed.id)) { + .onDrag { + FeedTransfer(feedID: feed.id).itemProvider + } preview: { FeedLabel(feed: feed) .padding(6) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) @@ -246,6 +266,15 @@ struct SidebarView: View { } } + private func move(_ providers: [NSItemProvider], toFolder folder: String?) -> Bool { + guard !providers.isEmpty else { return false } + Task { + let ids = await FeedTransfer.feedIDs(from: providers) + await viewModel.moveFeeds(ids: ids, toFolder: folder) + } + return true + } + private func badge(for filter: ArticleFilter) -> Int { switch filter { case .all, .unread: diff --git a/frontend-swift/Tests/FeedDragAndDropTests.swift b/frontend-swift/Tests/FeedDragAndDropTests.swift index ac9881f5..c037b6bc 100644 --- a/frontend-swift/Tests/FeedDragAndDropTests.swift +++ b/frontend-swift/Tests/FeedDragAndDropTests.swift @@ -23,30 +23,30 @@ final class FeedDragAndDropTests: XCTestCase { } func testTheDraggedPayloadUsesTheApplicationsOwnType() { - let provider = NSItemProvider() - provider.register(FeedTransfer(feedID: 1)) - - XCTAssertEqual(UTType.mrrssFeed.identifier, "com.devxdojo.mrrss.feed") + XCTAssertTrue(UTType.mrrssFeed.conforms(to: .data)) + XCTAssertFalse(UTType.mrrssFeed.conforms(to: .plainText)) + XCTAssertNotEqual(UTType.mrrssFeed, .data) XCTAssertEqual( - provider.registeredTypeIdentifiers, + FeedTransfer(feedID: 1).itemProvider.registeredTypeIdentifiers, [UTType.mrrssFeed.identifier], - "A subscription travels under an identifier of our own, not as generic text." + "A subscription must not travel under a broad type such as public.data, which the list claims for row reordering." ) } - func testTheDraggedPayloadSurvivesTheRoundTrip() async throws { - let provider = NSItemProvider() - provider.register(FeedTransfer(feedID: 42)) + func testTheDraggedPayloadSurvivesTheRoundTrip() async { + let providers = [FeedTransfer(feedID: 42), FeedTransfer(feedID: 7)].map(\.itemProvider) - XCTAssertTrue(provider.registeredTypeIdentifiers.contains(UTType.mrrssFeed.identifier)) + let ids = await FeedTransfer.feedIDs(from: providers) - let restored: FeedTransfer = try await withCheckedThrowingContinuation { continuation in - _ = provider.loadTransferable(type: FeedTransfer.self) { result in - continuation.resume(with: result) - } - } + XCTAssertEqual(ids, [42, 7]) + } + + func testAProviderOfAnotherKindContributesNothing() async { + let foreign = NSItemProvider(object: NSString(string: "https://example.com")) + + let ids = await FeedTransfer.feedIDs(from: [foreign, FeedTransfer(feedID: 3).itemProvider]) - XCTAssertEqual(restored, FeedTransfer(feedID: 42)) + XCTAssertEqual(ids, [3]) } func testDroppingOnAFolderMovesEveryFeedInTheDrag() async throws { diff --git a/frontend-swift/Tests/FeedDropTargetTests.swift b/frontend-swift/Tests/FeedDropTargetTests.swift new file mode 100644 index 00000000..9c4b3a11 --- /dev/null +++ b/frontend-swift/Tests/FeedDropTargetTests.swift @@ -0,0 +1,166 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import XCTest +@testable import MrRSS + +/// Enough of a dragging session to drive a real drop through AppKit. The drop +/// path cannot be reached from SwiftUI alone, and it is exactly where a +/// mismatch between the dragged type and the target's registered types goes +/// unnoticed: the drag still starts, and nothing accepts it. +private final class StubDraggingInfo: NSObject, NSDraggingInfo { + var draggingDestinationWindow: NSWindow? + var draggingSourceOperationMask: NSDragOperation = .move + var draggingLocation: NSPoint = .zero + var draggedImageLocation: NSPoint = .zero + var draggedImage: NSImage? + var draggingPasteboard: NSPasteboard + var draggingSource: Any? + var draggingSequenceNumber: Int = 1 + var draggingFormation: NSDraggingFormation = .default + var animatesToDestination: Bool = false + var numberOfValidItemsForDrop: Int = 1 + var springLoadingHighlight: NSSpringLoadingHighlight = .none + var items: [NSDraggingItem] = [] + + init(pasteboard: NSPasteboard) { + draggingPasteboard = pasteboard + super.init() + } + + func slideDraggedImage(to screenPoint: NSPoint) {} + + override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { + nil + } + + func enumerateDraggingItems( + options enumOpts: NSDraggingItemEnumerationOptions, + for view: NSView?, + classes classArray: [AnyClass], + searchOptions: [NSPasteboard.ReadingOptionKey: Any], + using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void + ) { + var stop: ObjCBool = false + for (index, item) in items.enumerated() { + block(item, index, &stop) + if stop.boolValue { return } + } + } + + func resetSpringLoading() {} +} + +@MainActor +final class FeedDropTargetTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "MrRSSDropTargetTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testDroppingOnAFolderFilesTheSubscription() async throws { + let (client, hostingView) = try await makeSidebar() + let target = try XCTUnwrap(folderDestination(in: hostingView)) + + try await drop(FeedTransfer(feedID: 1), on: target) + + XCTAssertEqual(client.categoryMutations.map(\.id), [1]) + XCTAssertEqual(client.categoryMutations.map(\.category), ["Tech"]) + } + + func testDroppingOnTheSectionHeaderTakesTheSubscriptionOutOfItsFolder() async throws { + let (client, hostingView) = try await makeSidebar() + let target = try XCTUnwrap(headerDestination(in: hostingView)) + + try await drop(FeedTransfer(feedID: 2), on: target) + + XCTAssertEqual(client.categoryMutations.map(\.id), [2]) + XCTAssertEqual(client.categoryMutations.map(\.category), [""]) + } + + func testTheDraggedTypeMatchesWhatTheTargetsAccept() throws { + // A type declared with UTType(exportedAs:) carries no conformances + // unless the bundle declares it too, and then no target accepts it. + XCTAssertTrue(UTType.mrrssFeed.conforms(to: .data)) + XCTAssertTrue(UTType.mrrssFeed.conforms(to: .item)) + XCTAssertFalse(UTType.mrrssFeed.conforms(to: .plainText)) + XCTAssertNotEqual(UTType.mrrssFeed, .data) + } + + private func drop(_ transfer: FeedTransfer, on target: NSView) async throws { + let item = NSPasteboardItem() + item.setData(try JSONEncoder().encode(transfer), forType: .init(UTType.mrrssFeed.identifier)) + + let pasteboard = NSPasteboard(name: .init("MrRSSDropTargetTests-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.writeObjects([item]) + + let info = StubDraggingInfo(pasteboard: pasteboard) + info.draggingDestinationWindow = target.window + info.draggingLocation = target.convert(NSPoint(x: 4, y: 4), to: nil) + info.items = [NSDraggingItem(pasteboardWriter: item)] + + XCTAssertNotEqual( + target.draggingEntered(info), [], + "The target refused the drag, so the pointer would show no drop cursor." + ) + XCTAssertTrue(target.performDragOperation(info)) + try await Task.sleep(for: .milliseconds(600)) + } + + private func makeSidebar() async throws -> (DelayedAPIClient, NSHostingView) { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""), + Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "Tech") + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 600), + styleMask: [.titled, .resizable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(600)) + return (client, hostingView) + } + + /// A folder covers most of the row; the section header covers its label + /// only, which is much narrower. + private func folderDestination(in view: NSView) -> NSView? { + dropDestinations(in: view).first { $0.bounds.width > 100 } + } + + private func headerDestination(in view: NSView) -> NSView? { + dropDestinations(in: view).first { $0.bounds.width <= 100 } + } + + private func dropDestinations(in view: NSView) -> [NSView] { + var found: [NSView] = [] + if !view.registeredDraggedTypes.isEmpty, !(view is NSTableView) { + found.append(view) + } + for subview in view.subviews { + found.append(contentsOf: dropDestinations(in: subview)) + } + return found + } +} diff --git a/frontend-swift/packaging/Info.plist b/frontend-swift/packaging/Info.plist index 51a9123b..f83e05e0 100644 --- a/frontend-swift/packaging/Info.plist +++ b/frontend-swift/packaging/Info.plist @@ -30,21 +30,6 @@ NSPrincipalClass NSApplication - UTExportedTypeDeclarations - - - UTTypeIdentifier - com.devxdojo.mrrss.feed - UTTypeDescription - MrRSS Subscription - UTTypeConformsTo - - public.data - - UTTypeTagSpecification - - - NSAppTransportSecurity NSAllowsLocalNetworking From 5cd426c4305b58e3841259bfcee9bddc2c4ba574 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Mon, 24 Aug 2026 22:07:25 +0800 Subject: [PATCH 16/59] feat(macos): reorder subscriptions by dropping between rows --- frontend-swift/README.md | 2 +- frontend-swift/Sources/Models/Models.swift | 7 +- .../Sources/Services/APIService.swift | 8 + .../Sources/ViewModels/AppViewModel.swift | 59 ++++++- .../Sources/Views/FeedRowDropDelegate.swift | 45 ++++++ .../Sources/Views/SidebarView.swift | 51 +++++- frontend-swift/Tests/AppViewModelTests.swift | 6 + .../Tests/FeedDropTargetTests.swift | 72 +-------- frontend-swift/Tests/FeedReorderTests.swift | 151 ++++++++++++++++++ frontend-swift/Tests/StubDraggingInfo.swift | 89 +++++++++++ 10 files changed, 418 insertions(+), 72 deletions(-) create mode 100644 frontend-swift/Sources/Views/FeedRowDropDelegate.swift create mode 100644 frontend-swift/Tests/FeedReorderTests.swift create mode 100644 frontend-swift/Tests/StubDraggingInfo.swift diff --git a/frontend-swift/README.md b/frontend-swift/README.md index 06a70862..4a373fdc 100644 --- a/frontend-swift/README.md +++ b/frontend-swift/README.md @@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. -Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. +Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. Dropping a subscription on the upper or lower half of another row files it directly before or after that row, and the sidebar shows a line where it will land. The resulting order is the `position` the server records, so it is shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. diff --git a/frontend-swift/Sources/Models/Models.swift b/frontend-swift/Sources/Models/Models.swift index 5ebd3efb..32e50601 100644 --- a/frontend-swift/Sources/Models/Models.swift +++ b/frontend-swift/Sources/Models/Models.swift @@ -5,11 +5,13 @@ struct Feed: Identifiable, Codable, Hashable { let url: String let title: String var category: String + /// Rank inside its category, as the server keeps it. + var position: Int let lastUpdated: String let iconURL: String? enum CodingKeys: String, CodingKey { - case id, url, title, category + case id, url, title, category, position case lastUpdated = "last_updated" case iconURL = "image_url" } @@ -19,6 +21,7 @@ struct Feed: Identifiable, Codable, Hashable { url: String, title: String, category: String, + position: Int = 0, lastUpdated: String = "", iconURL: String? = nil ) { @@ -26,6 +29,7 @@ struct Feed: Identifiable, Codable, Hashable { self.url = url self.title = title self.category = category + self.position = position self.lastUpdated = lastUpdated self.iconURL = iconURL?.nilIfEmpty } @@ -36,6 +40,7 @@ struct Feed: Identifiable, Codable, Hashable { url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" title = try container.decodeIfPresent(String.self, forKey: .title) ?? url category = try container.decodeIfPresent(String.self, forKey: .category) ?? "" + position = try container.decodeIfPresent(Int.self, forKey: .position) ?? 0 lastUpdated = try container.decodeIfPresent(String.self, forKey: .lastUpdated) ?? "" iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL)?.nilIfEmpty } diff --git a/frontend-swift/Sources/Services/APIService.swift b/frontend-swift/Sources/Services/APIService.swift index cf067ffa..d4ed2c51 100644 --- a/frontend-swift/Sources/Services/APIService.swift +++ b/frontend-swift/Sources/Services/APIService.swift @@ -74,6 +74,7 @@ protocol APIClient: AnyObject { func addFeed(url: String, title: String, category: String) async throws func deleteFeed(id: Int) async throws func updateFeedCategory(id: Int, category: String) async throws + func reorderFeed(id: Int, category: String, position: Int) async throws func refreshAllFeeds() async throws func fetchRefreshProgress() async throws -> RefreshProgress func fetchArticles( @@ -154,6 +155,13 @@ final class APIService: APIClient { ) } + func reorderFeed(id: Int, category: String, position: Int) async throws { + try await post( + "feeds/reorder", + jsonBody: ["feed_id": id, "category": category, "position": position] + ) + } + func refreshAllFeeds() async throws { try await post("refresh") } diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift index bef6f255..7facb006 100644 --- a/frontend-swift/Sources/ViewModels/AppViewModel.swift +++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift @@ -152,7 +152,7 @@ final class AppViewModel: ObservableObject { let (loadedFeeds, loadedCounts) = try await (feedsRequest, countsRequest) try Task.checkCancellation() guard requestID == feedRequestID else { return } - feeds = loadedFeeds + feeds = AppViewModel.ordered(loadedFeeds) refreshFolders() unreadCounts = loadedCounts connectionState = .connected @@ -301,6 +301,63 @@ final class AppViewModel: ObservableObject { } } + /// Files everything a drag carried directly above or below one of the rows + /// it was dropped on, which is how the sidebar's own order is changed. + func moveFeeds(ids: [Int], relativeTo referenceID: Int, placeAbove: Bool) async { + var anchorID = referenceID + var above = placeAbove + + for id in ids where id != referenceID { + guard let anchor = feeds.first(where: { $0.id == anchorID }), + feeds.contains(where: { $0.id == id }) else { continue } + + let siblings = feeds(inFolder: anchor.category).filter { $0.id != id } + let anchorIndex = siblings.firstIndex(where: { $0.id == anchorID }) ?? siblings.count + await reorderFeed(id: id, category: anchor.category, index: above ? anchorIndex : anchorIndex + 1) + + // Anything after the first lands just below what came before it, so + // a multiple selection keeps the order it was dragged in. + anchorID = id + above = false + } + } + + private func reorderFeed(id: Int, category: String, index: Int) async { + do { + try await api.reorderFeed(id: id, category: category, position: index) + } catch { + errorMessage = error.localizedDescription + refreshFeeds() + return + } + + applyLocalOrder(feedID: id, category: category, index: index) + refreshFolders() + } + + /// Mirrors the ranking the server just performed so the sidebar settles + /// immediately instead of waiting for the next load. + private func applyLocalOrder(feedID: Int, category: String, index: Int) { + guard var moving = feeds.first(where: { $0.id == feedID }) else { return } + moving.category = category + + var siblings = feeds.filter { $0.category == category && $0.id != feedID } + siblings.insert(moving, at: min(max(0, index), siblings.count)) + + for (rank, sibling) in siblings.enumerated() { + guard let position = feeds.firstIndex(where: { $0.id == sibling.id }) else { continue } + feeds[position].category = category + feeds[position].position = rank + } + feeds = AppViewModel.ordered(feeds) + } + + private static func ordered(_ feeds: [Feed]) -> [Feed] { + feeds.sorted { lhs, rhs in + lhs.position == rhs.position ? lhs.id < rhs.id : lhs.position < rhs.position + } + } + func renameFolder(_ folder: String, to newName: String) async { let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != folder else { return } diff --git a/frontend-swift/Sources/Views/FeedRowDropDelegate.swift b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift new file mode 100644 index 00000000..a48b6bf6 --- /dev/null +++ b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift @@ -0,0 +1,45 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// Handles a subscription dragged over another row. `DropInfo` reports where +/// the pointer sits inside the row, which is what decides whether the drop +/// lands above or below it; `onDrop(of:isTargeted:)` cannot tell. +struct FeedRowDropDelegate: DropDelegate { + let rowHeight: CGFloat + let onHover: (Bool) -> Void + let onExit: () -> Void + let onDrop: (Bool, [NSItemProvider]) -> Bool + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.mrrssFeed]) + } + + func dropEntered(info: DropInfo) { + onHover(placesAbove(info)) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + onHover(placesAbove(info)) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + onExit() + } + + func performDrop(info: DropInfo) -> Bool { + let above = placesAbove(info) + onExit() + return onDrop(above, info.itemProviders(for: [.mrrssFeed])) + } + + private func placesAbove(_ info: DropInfo) -> Bool { + info.location.y < rowHeight / 2 + } +} + +/// Where the sidebar will place a dragged subscription. +struct FeedInsertionPoint: Equatable { + let feedID: Int + let above: Bool +} diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index 95a315be..4041332c 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -10,6 +10,7 @@ struct SidebarView: View { @State private var folderPrompt: FolderPrompt? @State private var dropTargetFolder: String? @State private var isDropTargetingRoot = false + @State private var insertionPoint: FeedInsertionPoint? var body: some View { VStack(spacing: 0) { @@ -226,8 +227,36 @@ struct SidebarView: View { FeedLabel(feed: feed) .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) .tag(SidebarItem.feed(feed.id)) + .overlay(alignment: .top) { + insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: true)) + } + .overlay(alignment: .bottom) { + insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: false)) + } + .background { + GeometryReader { geometry in + Color.clear.onDrop( + of: [.mrrssFeed], + delegate: FeedRowDropDelegate( + rowHeight: geometry.size.height, + onHover: { above in + insertionPoint = FeedInsertionPoint(feedID: feed.id, above: above) + }, + onExit: { + if insertionPoint?.feedID == feed.id { + insertionPoint = nil + } + }, + onDrop: { above, providers in + insert(providers, relativeTo: feed, above: above) + } + ) + ) + } + } .onDrag { - FeedTransfer(feedID: feed.id).itemProvider + insertionPoint = nil + return FeedTransfer(feedID: feed.id).itemProvider } preview: { FeedLabel(feed: feed) .padding(6) @@ -266,6 +295,26 @@ struct SidebarView: View { } } + private func insertionIndicator(showing: Bool) -> some View { + Capsule() + .fill(Color.accentColor) + .frame(height: 2) + .padding(.horizontal, 2) + .opacity(showing ? 1 : 0) + .scaleEffect(x: showing ? 1 : 0.4, anchor: .leading) + .animation(.easeOut(duration: 0.12), value: showing) + .allowsHitTesting(false) + } + + private func insert(_ providers: [NSItemProvider], relativeTo feed: Feed, above: Bool) -> Bool { + guard !providers.isEmpty else { return false } + Task { + let ids = await FeedTransfer.feedIDs(from: providers) + await viewModel.moveFeeds(ids: ids, relativeTo: feed.id, placeAbove: above) + } + return true + } + private func move(_ providers: [NSItemProvider], toFolder folder: String?) -> Bool { guard !providers.isEmpty else { return false } Task { diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift index b5e461a2..87267c83 100644 --- a/frontend-swift/Tests/AppViewModelTests.swift +++ b/frontend-swift/Tests/AppViewModelTests.swift @@ -99,6 +99,7 @@ final class DelayedAPIClient: APIClient { private(set) var categoryMutations: [(id: Int, category: String)] = [] var categoryUpdateError: Error? private(set) var lastArticleQuery: (feedID: Int?, category: String?, filter: String)? + private(set) var reorderMutations: [(id: Int, category: String, position: Int)] = [] func checkConnection() async throws {} func fetchFeeds() async throws -> [Feed] { defaultFeeds } @@ -111,6 +112,11 @@ final class DelayedAPIClient: APIClient { if let categoryUpdateError { throw categoryUpdateError } categoryMutations.append((id, category)) } + + func reorderFeed(id: Int, category: String, position: Int) async throws { + if let categoryUpdateError { throw categoryUpdateError } + reorderMutations.append((id, category, position)) + } func refreshAllFeeds() async throws {} func fetchRefreshProgress() async throws -> RefreshProgress { RefreshProgress(isRunning: false) diff --git a/frontend-swift/Tests/FeedDropTargetTests.swift b/frontend-swift/Tests/FeedDropTargetTests.swift index 9c4b3a11..1713e8e3 100644 --- a/frontend-swift/Tests/FeedDropTargetTests.swift +++ b/frontend-swift/Tests/FeedDropTargetTests.swift @@ -4,53 +4,6 @@ import UniformTypeIdentifiers import XCTest @testable import MrRSS -/// Enough of a dragging session to drive a real drop through AppKit. The drop -/// path cannot be reached from SwiftUI alone, and it is exactly where a -/// mismatch between the dragged type and the target's registered types goes -/// unnoticed: the drag still starts, and nothing accepts it. -private final class StubDraggingInfo: NSObject, NSDraggingInfo { - var draggingDestinationWindow: NSWindow? - var draggingSourceOperationMask: NSDragOperation = .move - var draggingLocation: NSPoint = .zero - var draggedImageLocation: NSPoint = .zero - var draggedImage: NSImage? - var draggingPasteboard: NSPasteboard - var draggingSource: Any? - var draggingSequenceNumber: Int = 1 - var draggingFormation: NSDraggingFormation = .default - var animatesToDestination: Bool = false - var numberOfValidItemsForDrop: Int = 1 - var springLoadingHighlight: NSSpringLoadingHighlight = .none - var items: [NSDraggingItem] = [] - - init(pasteboard: NSPasteboard) { - draggingPasteboard = pasteboard - super.init() - } - - func slideDraggedImage(to screenPoint: NSPoint) {} - - override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { - nil - } - - func enumerateDraggingItems( - options enumOpts: NSDraggingItemEnumerationOptions, - for view: NSView?, - classes classArray: [AnyClass], - searchOptions: [NSPasteboard.ReadingOptionKey: Any], - using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void - ) { - var stop: ObjCBool = false - for (index, item) in items.enumerated() { - block(item, index, &stop) - if stop.boolValue { return } - } - } - - func resetSpringLoading() {} -} - @MainActor final class FeedDropTargetTests: XCTestCase { private var defaults: UserDefaults! @@ -99,23 +52,13 @@ final class FeedDropTargetTests: XCTestCase { } private func drop(_ transfer: FeedTransfer, on target: NSView) async throws { - let item = NSPasteboardItem() - item.setData(try JSONEncoder().encode(transfer), forType: .init(UTType.mrrssFeed.identifier)) - - let pasteboard = NSPasteboard(name: .init("MrRSSDropTargetTests-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.writeObjects([item]) - - let info = StubDraggingInfo(pasteboard: pasteboard) - info.draggingDestinationWindow = target.window - info.draggingLocation = target.convert(NSPoint(x: 4, y: 4), to: nil) - info.items = [NSDraggingItem(pasteboardWriter: item)] + let result = try SidebarDropProbe.drop(transfer, on: target, at: NSPoint(x: 4, y: 4)) XCTAssertNotEqual( - target.draggingEntered(info), [], + result.entered, [], "The target refused the drag, so the pointer would show no drop cursor." ) - XCTAssertTrue(target.performDragOperation(info)) + XCTAssertTrue(result.performed) try await Task.sleep(for: .milliseconds(600)) } @@ -154,13 +97,6 @@ final class FeedDropTargetTests: XCTestCase { } private func dropDestinations(in view: NSView) -> [NSView] { - var found: [NSView] = [] - if !view.registeredDraggedTypes.isEmpty, !(view is NSTableView) { - found.append(view) - } - for subview in view.subviews { - found.append(contentsOf: dropDestinations(in: subview)) - } - return found + SidebarDropProbe.destinations(in: view) } } diff --git a/frontend-swift/Tests/FeedReorderTests.swift b/frontend-swift/Tests/FeedReorderTests.swift new file mode 100644 index 00000000..f073cac9 --- /dev/null +++ b/frontend-swift/Tests/FeedReorderTests.swift @@ -0,0 +1,151 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import XCTest +@testable import MrRSS + +@MainActor +final class FeedReorderTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "MrRSSReorderTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testFeedsAreListedInTheOrderTheServerKeeps() async throws { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 2), + Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 0), + Feed(id: 3, url: "https://c.example.com/feed", title: "C", category: "", position: 1) + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + + XCTAssertEqual(viewModel.feeds.map(\.id), [2, 3, 1]) + } + + func testDroppingAboveARowRanksTheFeedBeforeIt() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.moveFeeds(ids: [3], relativeTo: 1, placeAbove: true) + + XCTAssertEqual(client.reorderMutations.map(\.id), [3]) + XCTAssertEqual(client.reorderMutations.map(\.position), [0]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [3, 1, 2]) + } + + func testDroppingBelowARowRanksTheFeedAfterIt() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.moveFeeds(ids: [3], relativeTo: 1, placeAbove: false) + + XCTAssertEqual(client.reorderMutations.map(\.position), [1]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 3, 2]) + } + + func testDroppingOnARowInsideAFolderFilesTheFeedThere() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.moveFeeds(ids: [1], relativeTo: 4, placeAbove: true) + + XCTAssertEqual(client.reorderMutations.map(\.category), ["Tech"]) + XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 4]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [2, 3]) + } + + func testDroppingARowOnItselfChangesNothing() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.moveFeeds(ids: [1], relativeTo: 1, placeAbove: true) + + XCTAssertTrue(client.reorderMutations.isEmpty) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 2, 3]) + } + + func testSeveralFeedsKeepTheOrderTheyWereDraggedIn() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.moveFeeds(ids: [2, 3], relativeTo: 1, placeAbove: true) + + XCTAssertEqual(client.reorderMutations.map(\.id), [2, 3]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [2, 3, 1]) + } + + func testAFailedReorderLeavesTheListAlone() async throws { + let (client, viewModel) = try await makeViewModel() + client.categoryUpdateError = URLError(.notConnectedToInternet) + + await viewModel.moveFeeds(ids: [3], relativeTo: 1, placeAbove: true) + + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 2, 3]) + } + + func testTheRowUnderThePointerDecidesWhereTheFeedLands() async throws { + for dropOnUpperHalf in [true, false] { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 0), + Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 1) + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 600), + styleMask: [.titled, .resizable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + hostingView.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(600)) + + // The first wide destination is the topmost row, which is feed A. + let rows = SidebarDropProbe.destinations(in: hostingView).filter { $0.bounds.width > 100 } + let target = try XCTUnwrap(rows.first) + + // The sidebar's views are flipped, so the smaller y is the top half. + let y = dropOnUpperHalf ? target.bounds.minY + 2 : target.bounds.maxY - 2 + let result = try SidebarDropProbe.drop(FeedTransfer(feedID: 2), on: target, at: NSPoint(x: 20, y: y)) + XCTAssertNotEqual(result.entered, []) + XCTAssertTrue(result.performed) + try await Task.sleep(for: .milliseconds(600)) + + XCTAssertEqual( + client.reorderMutations.map(\.position), + [dropOnUpperHalf ? 0 : 1], + "Dropping on the \(dropOnUpperHalf ? "upper" : "lower") half should rank the feed \(dropOnUpperHalf ? "before" : "after") the row." + ) + } + } + + private func makeViewModel() async throws -> (DelayedAPIClient, AppViewModel) { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 0), + Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 1), + Feed(id: 3, url: "https://c.example.com/feed", title: "C", category: "", position: 2), + Feed(id: 4, url: "https://d.example.com/feed", title: "D", category: "Tech", position: 0) + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + return (client, viewModel) + } +} diff --git a/frontend-swift/Tests/StubDraggingInfo.swift b/frontend-swift/Tests/StubDraggingInfo.swift new file mode 100644 index 00000000..ffe8f79d --- /dev/null +++ b/frontend-swift/Tests/StubDraggingInfo.swift @@ -0,0 +1,89 @@ +import AppKit +import UniformTypeIdentifiers +@testable import MrRSS + +/// Enough of a dragging session to drive a real drop through AppKit. The drop +/// path cannot be reached from SwiftUI alone, and it is exactly where a +/// mismatch between the dragged type and a target's registered types goes +/// unnoticed: the drag still starts, and nothing accepts it. +final class StubDraggingInfo: NSObject, NSDraggingInfo { + var draggingDestinationWindow: NSWindow? + var draggingSourceOperationMask: NSDragOperation = .move + var draggingLocation: NSPoint = .zero + var draggedImageLocation: NSPoint = .zero + var draggedImage: NSImage? + var draggingPasteboard: NSPasteboard + var draggingSource: Any? + var draggingSequenceNumber: Int = 1 + var draggingFormation: NSDraggingFormation = .default + var animatesToDestination: Bool = false + var numberOfValidItemsForDrop: Int = 1 + var springLoadingHighlight: NSSpringLoadingHighlight = .none + var items: [NSDraggingItem] = [] + + init(pasteboard: NSPasteboard) { + draggingPasteboard = pasteboard + super.init() + } + + func slideDraggedImage(to screenPoint: NSPoint) {} + + override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { + nil + } + + func enumerateDraggingItems( + options enumOpts: NSDraggingItemEnumerationOptions, + for view: NSView?, + classes classArray: [AnyClass], + searchOptions: [NSPasteboard.ReadingOptionKey: Any], + using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void + ) { + var stop: ObjCBool = false + for (index, item) in items.enumerated() { + block(item, index, &stop) + if stop.boolValue { return } + } + } + + func resetSpringLoading() {} +} + +enum SidebarDropProbe { + /// Drops `transfer` at a point given in the target's own coordinates. The + /// sidebar's views are flipped, so a larger y is further down the screen. + @MainActor + static func drop( + _ transfer: FeedTransfer, + on target: NSView, + at point: NSPoint + ) throws -> (entered: NSDragOperation, performed: Bool) { + let item = NSPasteboardItem() + item.setData(try JSONEncoder().encode(transfer), forType: .init(UTType.mrrssFeed.identifier)) + + let pasteboard = NSPasteboard(name: .init("MrRSSDropProbe-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.writeObjects([item]) + + let info = StubDraggingInfo(pasteboard: pasteboard) + info.draggingDestinationWindow = target.window + info.draggingLocation = target.convert(point, to: nil) + info.items = [NSDraggingItem(pasteboardWriter: item)] + + let entered = target.draggingEntered(info) + _ = target.draggingUpdated(info) + return (entered, target.performDragOperation(info)) + } + + @MainActor + static func destinations(in root: NSView) -> [NSView] { + var found: [NSView] = [] + if !root.registeredDraggedTypes.isEmpty, !(root is NSTableView) { + found.append(root) + } + for subview in root.subviews { + found.append(contentsOf: destinations(in: subview)) + } + return found + } +} From 70a1cab7caf26399859121319d285b05695a2aa8 Mon Sep 17 00:00:00 2001 From: WCY-dt <834421194@qq.com> Date: Tue, 25 Aug 2026 00:36:51 +0800 Subject: [PATCH 17/59] release: Upgrade version to v1.3.28 --- CHANGELOG.md | 2 ++ README.md | 2 +- README_zh.md | 2 +- build/config.yml | 4 ++-- build/linux/Taskfile.yml | 2 +- build/linux/nfpm/nfpm.yaml | 2 +- docs/SERVER_MODE/swagger.json | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- internal/version/version.go | 2 +- main-core.go | 2 +- skills/mrrss-assistant/references/api.md | 2 +- website/package-lock.json | 4 ++-- website/package.json | 2 +- 14 files changed, 18 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be755a3b..2f21f2a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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). +## [Unreleased] + ## [1.3.27] - 2026-08-22 ### Added diff --git a/README.md b/README.md index 794dc1c3..913afecb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ English | 简体中文

-[![Version](https://img.shields.io/badge/version-1.3.27-blue.svg)](https://github.com/DevXDojo/MrRSS/releases) +[![Version](https://img.shields.io/badge/version-1.3.28-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/) diff --git a/README_zh.md b/README_zh.md index 60fb9046..d34e7e82 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,7 +8,7 @@ English | 简体中文

-[![Version](https://img.shields.io/badge/version-1.3.27-blue.svg)](https://github.com/DevXDojo/MrRSS/releases) +[![Version](https://img.shields.io/badge/version-1.3.28-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/) diff --git a/build/config.yml b/build/config.yml index 2b4ef518..3e19293f 100644 --- a/build/config.yml +++ b/build/config.yml @@ -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.27" # The application version + version: "1.3.28" # 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.27" +# version: "1.3.28" # # The company/organisation name for templates and project settings # company: "Ch3nyang" # # Additional comments to embed in Info.plist metadata diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml index ce2a0950..6cd23c03 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.27' + VERSION: '1.3.28' ARCH: '{{.ARCH | default "amd64"}}' generate:dotdesktop: diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml index 0a39500b..c35c35ed 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.27" +version: "1.3.28" section: "default" priority: "extra" maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> diff --git a/docs/SERVER_MODE/swagger.json b/docs/SERVER_MODE/swagger.json index d7bbfd6a..7a8de4e6 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.27" + "version": "1.3.28" }, "host": "localhost:1234", "basePath": "/api", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1015cbe9..47c84169 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mrrss-frontend", - "version": "1.3.27", + "version": "1.3.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrrss-frontend", - "version": "1.3.27", + "version": "1.3.28", "dependencies": { "@eslint/js": "^10.0.1", "@phosphor-icons/vue": "^2.2.1", diff --git a/frontend/package.json b/frontend/package.json index c41f0fd8..cc7afa09 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mrrss-frontend", "private": true, - "version": "1.3.27", + "version": "1.3.28", "type": "module", "scripts": { "dev": "vite", diff --git a/internal/version/version.go b/internal/version/version.go index c91838ae..797f5719 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.27" +const Version = "1.3.28" diff --git a/main-core.go b/main-core.go index 04a9d9aa..3ce5e611 100644 --- a/main-core.go +++ b/main-core.go @@ -31,7 +31,7 @@ import ( ) // @title MrRSS API -// @version 1.3.27 +// @version 1.3.28 // @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/skills/mrrss-assistant/references/api.md b/skills/mrrss-assistant/references/api.md index 866d3eb3..6f1a1cf2 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.27` +- API version: `1.3.28` - API root: `{base_url}/api` - Endpoint paths below are relative to the API root unless they already start with `/api/`. diff --git a/website/package-lock.json b/website/package-lock.json index f8221aec..a745f994 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1,12 +1,12 @@ { "name": "mrrss-website", - "version": "1.3.27", + "version": "1.3.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrrss-website", - "version": "1.3.27", + "version": "1.3.28", "dependencies": { "@phosphor-icons/vue": "^2.2.1", "vue": "^3.5.41" diff --git a/website/package.json b/website/package.json index ba9f6283..cf0f91f5 100644 --- a/website/package.json +++ b/website/package.json @@ -1,6 +1,6 @@ { "name": "mrrss-website", - "version": "1.3.27", + "version": "1.3.28", "description": "MrRSS - AI-Powered RSS Reader Landing Page", "type": "module", "scripts": { From ee185d20b9a2426a1a59f8f727c8f03cccef01f7 Mon Sep 17 00:00:00 2001 From: Yuntsy <207946431@qq.com> Date: Tue, 25 Aug 2026 07:01:35 +0800 Subject: [PATCH 18/59] feat(macos): open a gap under the drag instead of drawing a bar --- frontend-swift/README.md | 2 +- .../Sources/ViewModels/AppViewModel.swift | 38 +++++ .../Sources/Views/FeedRowDropDelegate.swift | 6 - .../Sources/Views/SidebarView.swift | 146 +++++++++++++----- .../Tests/FeedDragPreviewTests.swift | 94 +++++++++++ 5 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 frontend-swift/Tests/FeedDragPreviewTests.swift diff --git a/frontend-swift/README.md b/frontend-swift/README.md index 4a373fdc..e4fadefe 100644 --- a/frontend-swift/README.md +++ b/frontend-swift/README.md @@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content. -Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. Dropping a subscription on the upper or lower half of another row files it directly before or after that row, and the sidebar shows a line where it will land. The resulting order is the `position` the server records, so it is shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. +Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. Dropping a subscription on the upper or lower half of another row files it directly before or after that row, and the rows around it open up while the drag is in flight to show where it would land. The resulting order is the `position` the server records, so it is shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it. diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift index 7facb006..0c9732d1 100644 --- a/frontend-swift/Sources/ViewModels/AppViewModel.swift +++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift @@ -28,6 +28,15 @@ enum ArticleFilter: String, CaseIterable, Identifiable { } } +/// The live picture of a drag in progress: which subscription is travelling, +/// and where the sidebar currently offers to put it. It drives the preview +/// only; nothing is written until the drop happens. +struct FeedDragSession: Equatable { + let feedID: Int + var folder: String + var index: Int +} + enum SidebarItem: Hashable { case filter(ArticleFilter) case folder(String) @@ -250,6 +259,23 @@ final class AppViewModel: ObservableObject { feeds.filter { $0.category == folder } } + /// The folder's rows as they should read while a drag is in flight: the + /// travelling subscription is lifted out of where it came from and shown + /// where it would land, so the rows around it open up for it. + func arrangedFeeds(inFolder folder: String, previewing session: FeedDragSession?) -> [Feed] { + var rows = feeds(inFolder: folder) + guard let session else { return rows } + + rows.removeAll { $0.id == session.feedID } + guard session.folder == folder, + let travelling = feeds.first(where: { $0.id == session.feedID }) else { + return rows + } + + rows.insert(travelling, at: min(max(0, session.index), rows.count)) + return rows + } + var unfiledFeeds: [Feed] { feeds.filter { $0.category.isEmpty } } @@ -322,6 +348,18 @@ final class AppViewModel: ObservableObject { } } + /// Commits what the drag preview was showing. + func placeFeed(id: Int, inFolder folder: String, at index: Int) async { + guard let feed = feeds.first(where: { $0.id == id }) else { return } + + let siblings = feeds(inFolder: folder).filter { $0.id != id } + let clamped = min(max(0, index), siblings.count) + let currentIndex = feeds(inFolder: folder).firstIndex(where: { $0.id == id }) + guard feed.category != folder || currentIndex != clamped else { return } + + await reorderFeed(id: id, category: folder, index: clamped) + } + private func reorderFeed(id: Int, category: String, index: Int) async { do { try await api.reorderFeed(id: id, category: category, position: index) diff --git a/frontend-swift/Sources/Views/FeedRowDropDelegate.swift b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift index a48b6bf6..2dff25bc 100644 --- a/frontend-swift/Sources/Views/FeedRowDropDelegate.swift +++ b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift @@ -37,9 +37,3 @@ struct FeedRowDropDelegate: DropDelegate { info.location.y < rowHeight / 2 } } - -/// Where the sidebar will place a dragged subscription. -struct FeedInsertionPoint: Equatable { - let feedID: Int - let above: Bool -} diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift index 4041332c..395d5a02 100644 --- a/frontend-swift/Sources/Views/SidebarView.swift +++ b/frontend-swift/Sources/Views/SidebarView.swift @@ -10,7 +10,8 @@ struct SidebarView: View { @State private var folderPrompt: FolderPrompt? @State private var dropTargetFolder: String? @State private var isDropTargetingRoot = false - @State private var insertionPoint: FeedInsertionPoint? + @State private var dragSession: FeedDragSession? + @State private var hoverGeneration = 0 var body: some View { VStack(spacing: 0) { @@ -81,11 +82,21 @@ struct SidebarView: View { // of whatever folder it is in. Text("Feeds") .padding(.horizontal, 4) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(isDropTargetingRoot ? Color.accentColor.opacity(0.25) : .clear) - ) - .onDrop(of: [.mrrssFeed], isTargeted: $isDropTargetingRoot) { providers in + .background(dropHighlight(active: isDropTargetingRoot)) + .onDrop( + of: [.mrrssFeed], + isTargeted: Binding( + get: { isDropTargetingRoot }, + set: { isTargeted in + isDropTargetingRoot = isTargeted + if isTargeted { + previewMove(intoFolder: "") + } else { + scheduleDragSessionClear() + } + } + ) + ) { providers in move(providers, toFolder: nil) } } @@ -177,7 +188,7 @@ struct SidebarView: View { } ) ) { - ForEach(viewModel.feeds(inFolder: folder)) { feed in + ForEach(viewModel.arrangedFeeds(inFolder: folder, previewing: dragSession)) { feed in feedRow(feed) } } label: { @@ -192,10 +203,7 @@ struct SidebarView: View { .tag(SidebarItem.folder(folder)) .padding(.vertical, 2) .padding(.horizontal, 4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(dropTargetFolder == folder ? Color.accentColor.opacity(0.25) : .clear) - ) + .background(dropHighlight(active: dropTargetFolder == folder)) .onDrop( of: [.mrrssFeed], isTargeted: Binding( @@ -204,8 +212,10 @@ struct SidebarView: View { if isTargeted { dropTargetFolder = folder expandedFolders.insert(folder) + previewMove(intoFolder: folder) } else if dropTargetFolder == folder { dropTargetFolder = nil + scheduleDragSessionClear() } } ) @@ -227,35 +237,24 @@ struct SidebarView: View { FeedLabel(feed: feed) .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0) .tag(SidebarItem.feed(feed.id)) - .overlay(alignment: .top) { - insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: true)) - } - .overlay(alignment: .bottom) { - insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: false)) - } + .opacity(dragSession?.feedID == feed.id ? 0.4 : 1) .background { GeometryReader { geometry in Color.clear.onDrop( of: [.mrrssFeed], delegate: FeedRowDropDelegate( rowHeight: geometry.size.height, - onHover: { above in - insertionPoint = FeedInsertionPoint(feedID: feed.id, above: above) - }, - onExit: { - if insertionPoint?.feedID == feed.id { - insertionPoint = nil - } - }, + onHover: { above in previewInsertion(near: feed, above: above) }, + onExit: { scheduleDragSessionClear() }, onDrop: { above, providers in - insert(providers, relativeTo: feed, above: above) + drop(providers, near: feed, above: above) } ) ) } } .onDrag { - insertionPoint = nil + startDragSession(for: feed) return FeedTransfer(feedID: feed.id).itemProvider } preview: { FeedLabel(feed: feed) @@ -295,28 +294,99 @@ struct SidebarView: View { } } - private func insertionIndicator(showing: Bool) -> some View { - Capsule() - .fill(Color.accentColor) - .frame(height: 2) - .padding(.horizontal, 2) - .opacity(showing ? 1 : 0) - .scaleEffect(x: showing ? 1 : 0.4, anchor: .leading) - .animation(.easeOut(duration: 0.12), value: showing) - .allowsHitTesting(false) + private func startDragSession(for feed: Feed) { + let siblings = viewModel.feeds(inFolder: feed.category) + dragSession = FeedDragSession( + feedID: feed.id, + folder: feed.category, + index: siblings.firstIndex(where: { $0.id == feed.id }) ?? siblings.count + ) + } + + /// Opens a gap where the subscription would land. Hovering the travelling + /// row itself changes nothing, which is what keeps the preview from + /// oscillating as the rows move out of the way. + private func previewInsertion(near feed: Feed, above: Bool) { + hoverGeneration += 1 + guard let session = dragSession, session.feedID != feed.id else { return } + + let siblings = viewModel.feeds(inFolder: feed.category).filter { $0.id != session.feedID } + let anchor = siblings.firstIndex(where: { $0.id == feed.id }) ?? siblings.count + let updated = FeedDragSession( + feedID: session.feedID, + folder: feed.category, + index: above ? anchor : anchor + 1 + ) + + guard updated != session else { return } + withAnimation(.snappy(duration: 0.18)) { + dragSession = updated + } + } + + /// A drag that leaves one row usually enters another straight away, so the + /// preview is only dropped once nothing has claimed it. + private func scheduleDragSessionClear() { + hoverGeneration += 1 + let generation = hoverGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + guard generation == hoverGeneration else { return } + withAnimation(.snappy(duration: 0.18)) { + dragSession = nil + } + } } - private func insert(_ providers: [NSItemProvider], relativeTo feed: Feed, above: Bool) -> Bool { + private func drop(_ providers: [NSItemProvider], near feed: Feed, above: Bool) -> Bool { guard !providers.isEmpty else { return false } + + let session = dragSession + withAnimation(.snappy(duration: 0.18)) { + dragSession = nil + } + Task { let ids = await FeedTransfer.feedIDs(from: providers) - await viewModel.moveFeeds(ids: ids, relativeTo: feed.id, placeAbove: above) + // Commit what the preview was showing; a drag from elsewhere has no + // preview, so fall back to the row the pointer is on. + if let session, ids == [session.feedID] { + await viewModel.placeFeed(id: session.feedID, inFolder: session.folder, at: session.index) + } else { + await viewModel.moveFeeds(ids: ids, relativeTo: feed.id, placeAbove: above) + } } return true } + /// The system's own drop tint, rather than a colour of our choosing. + private func dropHighlight(active: Bool) -> some View { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .selectedContentBackgroundColor).opacity(active ? 0.28 : 0)) + .animation(.easeOut(duration: 0.12), value: active) + } + + /// Lifts the travelling subscription out of the list it came from while the + /// pointer rests on a folder, so the gap it leaves behind closes up. + private func previewMove(intoFolder folder: String) { + hoverGeneration += 1 + guard let session = dragSession else { return } + + let updated = FeedDragSession( + feedID: session.feedID, + folder: folder, + index: viewModel.feeds(inFolder: folder).filter { $0.id != session.feedID }.count + ) + guard updated != session else { return } + withAnimation(.snappy(duration: 0.18)) { + dragSession = updated + } + } + private func move(_ providers: [NSItemProvider], toFolder folder: String?) -> Bool { guard !providers.isEmpty else { return false } + withAnimation(.snappy(duration: 0.18)) { + dragSession = nil + } Task { let ids = await FeedTransfer.feedIDs(from: providers) await viewModel.moveFeeds(ids: ids, toFolder: folder) diff --git a/frontend-swift/Tests/FeedDragPreviewTests.swift b/frontend-swift/Tests/FeedDragPreviewTests.swift new file mode 100644 index 00000000..227e4384 --- /dev/null +++ b/frontend-swift/Tests/FeedDragPreviewTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import MrRSS + +@MainActor +final class FeedDragPreviewTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "MrRSSDragPreviewTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testWithoutADragTheRowsReadInTheirStoredOrder() async throws { + let (_, viewModel) = try await makeViewModel() + + XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: nil).map(\.id), [1, 2, 3]) + } + + func testTheRowsOpenUpWhereTheSubscriptionWouldLand() async throws { + let (_, viewModel) = try await makeViewModel() + + // C is on its way to the top, so A and B move down to make room. + let session = FeedDragSession(feedID: 3, folder: "", index: 0) + + XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: session).map(\.id), [3, 1, 2]) + } + + func testTheGapLeftBehindClosesWhileTheSubscriptionIsOverAnotherFolder() async throws { + let (_, viewModel) = try await makeViewModel() + + let session = FeedDragSession(feedID: 1, folder: "Tech", index: 0) + + XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: session).map(\.id), [2, 3]) + XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "Tech", previewing: session).map(\.id), [1, 4]) + } + + func testAnIndexBeyondTheListLandsAtTheEnd() async throws { + let (_, viewModel) = try await makeViewModel() + + let session = FeedDragSession(feedID: 1, folder: "", index: 99) + + XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: session).map(\.id), [2, 3, 1]) + } + + func testCommittingThePreviewRanksTheSubscriptionThere() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.placeFeed(id: 3, inFolder: "", at: 0) + + XCTAssertEqual(client.reorderMutations.map(\.id), [3]) + XCTAssertEqual(client.reorderMutations.map(\.position), [0]) + XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [3, 1, 2]) + } + + func testCommittingAPreviewThatChangesNothingSkipsTheServer() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.placeFeed(id: 2, inFolder: "", at: 1) + + XCTAssertTrue(client.reorderMutations.isEmpty) + } + + func testCommittingAPreviewIntoAnotherFolderFilesItThere() async throws { + let (client, viewModel) = try await makeViewModel() + + await viewModel.placeFeed(id: 1, inFolder: "Tech", at: 0) + + XCTAssertEqual(client.reorderMutations.map(\.category), ["Tech"]) + XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 4]) + } + + private func makeViewModel() async throws -> (DelayedAPIClient, AppViewModel) { + let client = DelayedAPIClient() + client.defaultFeeds = [ + Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 0), + Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 1), + Feed(id: 3, url: "https://c.example.com/feed", title: "C", category: "", position: 2), + Feed(id: 4, url: "https://d.example.com/feed", title: "D", category: "Tech", position: 0) + ] + let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults) + viewModel.refreshFeeds() + try await Task.sleep(for: .milliseconds(250)) + return (client, viewModel) + } +} From 9d0f3346d6c49709016611ea86f3e4178848b764 Mon Sep 17 00:00:00 2001 From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:49:46 +0800 Subject: [PATCH 19/59] fix(ui): prevent long toast messages from overflowing --- frontend/src/App.test.ts | 9 +++++++++ frontend/src/components/common/Toast.vue | 21 +++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 2605314e..6ab72799 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { createPinia } from 'pinia'; @@ -15,6 +16,14 @@ const createStub = (name: string) => ({ }); describe('App', () => { + it('keeps long toast messages inside narrow viewports', () => { + const toast = readFileSync('src/components/common/Toast.vue', 'utf8'); + expect(toast).toContain('calc(100vw-2rem)'); + expect(toast).toContain('overflow-wrap: anywhere'); + expect(toast).toContain('min-w-0 flex-1'); + expect(toast).toContain('shrink-0'); + }); + it('renders and reacts to interface typography settings', async () => { setSettingsFromRawData({}); const pinia = createPinia(); diff --git a/frontend/src/components/common/Toast.vue b/frontend/src/components/common/Toast.vue index b0f32238..6b7d3716 100644 --- a/frontend/src/components/common/Toast.vue +++ b/frontend/src/components/common/Toast.vue @@ -38,13 +38,16 @@ function handleClose() {