72 Commits

Author SHA1 Message Date
Renovate Bot
2147a0b15f Update https://git.quad4.io/actions/checkout action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 1m2s
2025-12-29 20:05:34 +00:00
ec011f7442 Merge pull request 'Update dependency cookie to v1' (#6) from renovate/cookie-1.x into master
All checks were successful
CI / build-frontend (push) Successful in 1m44s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m38s
CI / build-backend (push) Successful in 9m37s
Reviewed-on: #6
2025-12-29 20:02:14 +00:00
5228640a21 Merge pull request 'Update dependency gradle to v9' (#7) from renovate/gradle-9.x into master
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 1m2s
CI / build-frontend (push) Successful in 1m3s
CI / build-backend (push) Successful in 43s
Reviewed-on: #7
2025-12-29 20:02:05 +00:00
49df78f553 Update CI and workflow files to use custom action URLs instead of default GitHub actions. Remove obsolete renovate.yml workflow file.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 38s
CI / build-frontend (push) Successful in 1m33s
CI / build-backend (push) Successful in 59s
2025-12-28 20:58:54 -06:00
ivan
5549171165 Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11' (#12) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
renovate / renovate (push) Failing after 27s
CI / build-backend (push) Has been skipped
CI / build-frontend (push) Failing after 29s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
Reviewed-on: #12
2025-12-29 00:21:33 +00:00
Renovate Bot
39f2986150 Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m36s
2025-12-29 00:04:22 +00:00
ivan
e0f85b450c Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42' (#9) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
CI / build-frontend (push) Failing after 9s
CI / build-backend (push) Has been skipped
renovate / renovate (push) Failing after 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m35s
Reviewed-on: #9
2025-12-28 18:53:37 +00:00
Renovate Bot
19a9e0506d Update ghcr.io/renovatebot/renovate Docker tag to v42
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m35s
2025-12-28 14:53:19 +00:00
3cdca944ef 0.2.3
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 54s
CI / build-frontend (push) Successful in 1m6s
CI / build-backend (push) Successful in 36s
Build and Publish Docker Image / build (push) Successful in 11m1s
Publish / publish (push) Successful in 33m35s
2025-12-27 21:27:15 -06:00
895fba9ded Update fetching and filtering capabilities by adding category support in GetArticles methods across multiple files. Update NewsStore to manage selected category state and integrate category filtering in article loading. Improve feed fetching with abort signal handling in fetchFeed function. Update UI components to reflect category selection and enhance user experience with new feed health management features.
Some checks failed
renovate / renovate (push) Failing after 13s
CI / build-frontend (push) Successful in 50s
CI / build-backend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
2025-12-27 21:26:28 -06:00
a4503563e3 Update version to 0.2.2 in package.json
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 35s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
2025-12-27 21:01:03 -06:00
82da01ca45 Add OPML import functionality in AddFeedModal component, including file handling and toast notifications. Update ArticleCard to display feed icons and enhance article source display. Modify NewsStore to track last articles update and improve article refresh logic. Update UI components for better user experience and consistency. 2025-12-27 21:00:36 -06:00
7e235cb9d1 Format configuration files for consistency and readability. Update docker-compose.coolify.yml to use single quotes for labels. Adjust pnpm-lock.yaml to format resolution entries uniformly. Clean up README.md by removing an empty checklist item. Standardize quotes in renovate.json and .gitea/workflows/renovate.yml. Enhance HTML structure in app.html for better readability. 2025-12-27 20:32:18 -06:00
a626a7cb33 Add newlyRegisteredToken state to NewsStore and implement account number display and copy functionality in the registration flow. Update UI to show success message upon registration and allow users to copy their account number to clipboard. 2025-12-27 20:32:06 -06:00
2c6bee84b4 Add caching support in main.go and related files, including SQLite integration for cache storage. Update Docker configurations to include cache settings and enable public instance optimizations.
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 51s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m31s
CI / build-backend (push) Successful in 9m36s
2025-12-27 20:25:38 -06:00
965c2b6daf Update version to 0.2.1
Some checks failed
renovate / renovate (push) Failing after 14s
CI / build-frontend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m32s
CI / build-backend (push) Successful in 9m27s
2025-12-27 20:13:28 -06:00
20b83eb052 Update sidebar version display to use dynamic APP_VERSION import for improved maintainability 2025-12-27 20:13:17 -06:00
9bd06c1f70 Add rate limiting environment variables to docker-compose.coolify.yml for improved service configuration
Some checks failed
renovate / renovate (push) Failing after 20s
CI / build-frontend (push) Successful in 47s
CI / build-backend (push) Successful in 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
2025-12-27 20:09:28 -06:00
0765f47083 Add rate limiting environment variables to Dockerfile for enhanced configuration 2025-12-27 20:09:23 -06:00
0b7c15f2f0 Fix rate limit environment variable handling in main.go to improve parsing logic and remove redundant code 2025-12-27 20:09:16 -06:00
7180776daa Add rate limiting configuration options in main.go and implement SetLimit method in RateLimiter to dynamically adjust rate limits based on environment variables 2025-12-27 20:08:20 -06:00
4ed6fcd752 Add GetRealIP function to improve IP retrieval in middleware; update BotBlockerMiddleware to use new function for logging blocked requests
Some checks failed
renovate / renovate (push) Failing after 21s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 26s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
2025-12-27 20:01:05 -06:00
4e364bec74 Fix authentication flags in main.go to support environment variable defaults for auth mode, auth file, registration allowance, hashes file, and protection settings 2025-12-27 20:01:00 -06:00
8e41a88599 Update docker-compose.coolify.yml to enhance environment variable defaults and add Traefik rate limiting labels 2025-12-27 20:00:49 -06:00
eece10388a Update service worker cache version to 0.2.0
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 49s
CI / build-backend (push) Successful in 24s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
Build and Publish Docker Image / build (push) Successful in 10m8s
Publish / publish (push) Successful in 33m56s
2025-12-27 19:51:14 -06:00
4e308f2e61 Add SEO and social sharing features by adding Open Graph and Twitter meta tags across app.html and share page. Update sidebar layout and version to 0.2.0. 2025-12-27 19:51:10 -06:00
3deba1ad94 Update error handling in SaveHashes method of RateLimiter to log marshaling and file writing errors 2025-12-27 19:50:59 -06:00
d0c96c7ca5 Update pnpm-lock.yaml to reflect dependency version changes and improve formatting 2025-12-27 19:50:49 -06:00
5d049d38b7 Update package.json to version 0.2.0 and upgrade dependencies 2025-12-27 19:50:44 -06:00
bb3ba5ecd4 Update Dockerfile to version 0.2.0 2025-12-27 19:50:37 -06:00
25182b6831 Fix ALLOWED_ORIGINS environment variable in docker-compose.coolify.yml to remove default fallback 2025-12-27 19:50:28 -06:00
a37f6fa7bf Update README.md with new feature checkboxes and build requirements 2025-12-27 19:50:17 -06:00
Renovate Bot
cafe5b8fd0 Update dependency gradle to v9
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 42s
2025-12-28 01:10:21 +00:00
Renovate Bot
3534ba9b89 Update dependency cookie to v1
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 42s
2025-12-28 01:10:07 +00:00
ivan
205eb3e99d Merge pull request 'Update module git.quad4.io/Quad4-Software/webnews to v0.1.0' (#4) from renovate/git.quad4.io-quad4-software-webnews-0.x into master
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 43s
CI / build-frontend (push) Successful in 1m2s
CI / build-backend (push) Successful in 27s
Reviewed-on: #4
2025-12-28 00:42:12 +00:00
ivan
6bdc0ad29c Merge pull request 'Update dependency com.android.tools.build:gradle to v8.13.2' (#2) from renovate/com.android.tools.build-gradle-8.x into master
Some checks failed
renovate / renovate (push) Failing after 45s
CI / build-frontend (push) Successful in 1m14s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
CI / build-backend (push) Successful in 9m29s
Reviewed-on: #2
2025-12-28 00:42:04 +00:00
ivan
3668aa8e78 Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v37.440.7' (#3) from renovate/ghcr.io-renovatebot-renovate-37.x into master
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 59s
CI / build-frontend (push) Successful in 1m2s
CI / build-backend (push) Successful in 26s
Reviewed-on: #3
2025-12-28 00:41:52 +00:00
Renovate Bot
ec0b6b3262 Update module git.quad4.io/Quad4-Software/webnews to v0.1.0
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 1m0s
2025-12-28 00:02:08 +00:00
Renovate Bot
6a35e95814 Update ghcr.io/renovatebot/renovate Docker tag to v37.440.7
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m56s
2025-12-27 22:31:19 +00:00
Renovate Bot
38398d79be Update dependency com.android.tools.build:gradle to v8.13.2
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m59s
2025-12-27 22:31:17 +00:00
ivan
f92922ae8e Merge pull request 'Configure Renovate' (#1) from renovate/configure into master
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 48s
CI / build-frontend (push) Successful in 57s
CI / build-backend (push) Successful in 28s
renovate / renovate (push) Failing after 7s
Reviewed-on: #1
2025-12-27 20:55:00 +00:00
Renovate Bot
4210e0a0c1 Add renovate.json
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 39s
2025-12-27 20:49:16 +00:00
ce13ec2d6f Add Renovate workflow
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 49s
CI / build-frontend (push) Successful in 1m16s
CI / build-backend (push) Successful in 38s
renovate / renovate (push) Failing after 10s
2025-12-27 14:29:17 -06:00
5bc7630673 Update Go setup action version in OSV workflows for consistency
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 56s
CI / build-frontend (push) Successful in 1m6s
CI / build-backend (push) Successful in 32s
2025-12-27 12:42:53 -06:00
b402f078e7 Add Go setup step in OSV workflows for improved environment configuration
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Failing after 46s
CI / build-frontend (push) Successful in 1m33s
CI / build-backend (push) Successful in 26s
2025-12-27 12:38:12 -06:00
b2306798ac Refactor OSV scan script to simplify vulnerability reporting
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 20s
CI / build-frontend (push) Successful in 1m14s
CI / build-backend (push) Successful in 34s
2025-12-27 12:35:57 -06:00
50399bcae2 Update CI and publish workflows to use specific pnpm action version and add Android SDK setup
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 28s
- Changed pnpm action setup to a specific commit version for consistency in CI and publish workflows.
- Added Android SDK setup step in the publish workflow to streamline Android development environment.
2025-12-27 12:32:36 -06:00
3f62b36de5 Refactor code for improved readability and consistency
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 11s
CI / build-frontend (push) Successful in 1m29s
CI / build-backend (push) Successful in 26s
- Cleaned up formatting in app.css and db.ts by removing unnecessary blank lines and ensuring consistent indentation.
- Enhanced readability in various files by adding missing semicolons and adjusting line breaks for better clarity.
- Updated function signatures and object definitions for improved type consistency in TypeScript files.
2025-12-27 12:26:57 -06:00
c5176f9ed4 Refactor Svelte components for improved formatting and consistency
- Cleaned up formatting in AddFeedModal.svelte, ArticleCard.svelte, Navbar.svelte, Sidebar.svelte, and Toasts.svelte by adding missing commas and adjusting indentation.
- Enhanced readability by ensuring consistent use of line breaks and spacing across components.
- Updated event handlers and bindings for better clarity and maintainability.
2025-12-27 12:26:48 -06:00
30c7a50240 Update build and check scripts to use pnpm for consistency
- Changed build command in build.sh from npm to pnpm.
- Updated dependency installation in publish_setup.sh to use pnpm.
- Modified check.sh to replace npx commands with pnpm for running Svelte sync and checks.
2025-12-27 12:26:39 -06:00
f98fc7c618 Remove unnecessary blank line in inject-sw-version.js for cleaner code 2025-12-27 12:26:31 -06:00
402f4e676e Update wails.json to use pnpm for frontend dependency management
- Changed frontend installation and build commands from npm to pnpm for consistency with recent updates across the project.
2025-12-27 12:26:21 -06:00
78ec006842 Update CI and publish workflows to integrate pnpm for dependency management
- Added pnpm installation step in both CI and publish workflows.
- Updated dependency installation command to use pnpm instead of npm for consistency and improved performance.
- Configured pnpm cache setup in CI workflow to optimize build times.
2025-12-27 12:26:16 -06:00
7a8674f0db Update vite.config.ts to add trailing comma for consistency in server proxy configuration 2025-12-27 12:26:09 -06:00
44b11cf3e8 Refactor tailwind.config.js to streamline plugins array formatting 2025-12-27 12:26:03 -06:00
6a6c39ce1b Update Svelte configuration files to ensure consistent formatting
- Added a trailing comma in the configuration objects of both svelte.config.js and svelte.config.docker.js for improved readability and consistency.
2025-12-27 12:25:58 -06:00
30b5f48349 Update README.md to include To-Do list and prerequisites for pnpm
- Added a To-Do section outlining future enhancements such as reading time and UI/UX cleanup.
- Updated prerequisites to include pnpm 9+ for consistency in dependency management.
2025-12-27 12:25:50 -06:00
3258ee94cf Update Dockerfile to set user context and improve dependency installation
- Changed user to root for installing pnpm globally, then switched back to node for application work.
- Updated WORKDIR to ensure proper directory context for the application build process.
2025-12-27 12:25:38 -06:00
db764ede58 Remove package-lock.json and update package.json to use pnpm for packaging and add cookie override 2025-12-27 12:25:32 -06:00
d6bd993abd Update Makefile to use pnpm for dependency management
- Replaced npm commands with pnpm for installing dependencies and running development scripts, enhancing consistency and performance.
- Adjusted the android-build and frontend-build targets to utilize pnpm for synchronization and building processes.
2025-12-27 12:25:23 -06:00
53d1fdbd21 Refactor Dockerfile to use pnpm for dependency management
- Replaced npm with pnpm for installing dependencies, improving installation speed and consistency.
- Updated the Dockerfile to copy pnpm-lock.yaml and use the frozen lockfile option during installation.
- Adjusted build command to utilize pnpm for building the frontend.
2025-12-27 12:24:42 -06:00
b5d0102c27 Add pnpm lockfile for dependency management
- Created pnpm-lock.yaml to manage project dependencies and ensure consistent installations.
- Included various packages such as Capacitor, TailwindCSS, and Svelte with their respective versions for improved project stability.
2025-12-27 12:24:26 -06:00
55d599dd2f format capacitor.config.ts to include trailing comma for consistency 2025-12-27 12:24:16 -06:00
390c70cff1 format compose files 2025-12-27 12:24:02 -06:00
ec37403369 Add Android build process and CI configuration
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-frontend (push) Successful in 41s
CI / build-backend (push) Successful in 24s
- Updated Makefile to remove explicit JAVA_HOME export, simplifying the android-build command.
- Added Java setup step in the Gitea workflow to ensure the correct Java version is used during builds.
- Modified publish_build.sh to include the Android APK build step and copy the generated APK to the distribution directory.
2025-12-27 12:11:20 -06:00
ab67cae648 Add build arguments to Docker workflow for enhanced image metadata
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-frontend (push) Successful in 41s
CI / build-backend (push) Successful in 25s
- Included BUILD_DATE, VCS_REF, and VERSION as build arguments in the Docker workflow to improve image documentation and traceability.
2025-12-27 12:01:41 -06:00
0ef2477403 Update Docker build process in Makefile
- Added build arguments for BUILD_DATE, VCS_REF, and VERSION to the docker build command for improved image metadata.
2025-12-27 12:01:34 -06:00
dd6c743915 Add Docker Compose files for web-news service configuration
- Introduced docker-compose.coolify.yml for Coolify deployment.
- Added docker-compose.prod.yml for production environment setup.
- Created docker-compose.yml for local development with port mapping.
- Configured environment variables and volume management for data persistence.
2025-12-27 12:01:18 -06:00
46fb66ca05 Update Dockerfile for improved build efficiency and runtime configuration
- Implemented caching for npm and Go module downloads to speed up builds.
- Updated base image for the final runtime stage to a more minimal static image.
- Added metadata labels for better image documentation and versioning.
- Created a data directory for configuration files and adjusted ownership.
- Modified the command to include paths for authentication and hashes files.
2025-12-27 12:01:03 -06:00
6289ffc01d Update Docker workflow to change image name from linking-tool to webnews
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-frontend (push) Successful in 44s
CI / build-backend (push) Successful in 27s
2025-12-27 02:31:54 -06:00
8334b4487d Replace favicons
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / build-frontend (push) Successful in 1m0s
CI / build-backend (push) Successful in 25s
2025-12-27 02:28:01 -06:00
d07f3f7ee0 Remove npm publish workflow 2025-12-27 02:27:50 -06:00
55 changed files with 6707 additions and 7081 deletions

View File

@@ -11,20 +11,39 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install pnpm
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: https://git.quad4.io/actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install --frozen-lockfile
- name: Frontend checks - name: Frontend checks
run: bash scripts/check.sh run: bash scripts/check.sh
- name: Build frontend - name: Build frontend
run: bash scripts/build.sh run: bash scripts/build.sh
- name: Upload frontend assets - name: Upload frontend assets
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3 uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with: with:
name: frontend-build name: frontend-build
path: build/ path: build/
@@ -34,14 +53,14 @@ jobs:
needs: build-frontend needs: build-frontend
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download frontend assets - name: Download frontend assets
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with: with:
name: frontend-build name: frontend-build
path: build/ path: build/
- name: Setup Go - name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version: '1.25.4' go-version: '1.25.4'
- name: Build backend - name: Build backend

View File

@@ -8,7 +8,7 @@ on:
env: env:
REGISTRY: git.quad4.io REGISTRY: git.quad4.io
IMAGE_NAME: quad4-software/linking-tool IMAGE_NAME: quad4-software/webnews
jobs: jobs:
build: build:
@@ -22,18 +22,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
with: with:
platforms: amd64,arm64 platforms: amd64,arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
@@ -41,7 +41,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
@@ -54,7 +54,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -62,3 +62,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta.outputs.version }}

View File

@@ -1,37 +0,0 @@
name: Publish NPM Package
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --registry=https://registry.npmjs.org/
- name: Package
run: make package
- name: Configure npm for publishing
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
- name: Publish
run: npm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -14,7 +14,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: 'go.mod'
- name: OSV scan - name: OSV scan
run: bash scripts/osv_scan.sh run: bash scripts/osv_scan.sh

View File

@@ -14,7 +14,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: 'go.mod'
- name: OSV scan - name: OSV scan
run: bash scripts/osv_scan.sh run: bash scripts/osv_scan.sh

View File

@@ -11,19 +11,36 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install pnpm
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm cache: pnpm
- name: Setup Go - name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version: '1.25.4' go-version: '1.25.4'
- name: Setup Java
uses: https://git.quad4.io/actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Setup Android SDK
uses: https://git.quad4.io/actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
log-accepted-android-sdk-licenses: false
- name: Install dependencies - name: Install dependencies
run: bash scripts/publish_setup.sh run: bash scripts/publish_setup.sh
@@ -39,4 +56,3 @@ jobs:
REPO_NAME: ${{ gitea.event.repository.name }} REPO_NAME: ${{ gitea.event.repository.name }}
SERVER_URL: ${{ gitea.server_url }} SERVER_URL: ${{ gitea.server_url }}
run: bash scripts/publish.sh run: bash scripts/publish.sh

View File

@@ -1,31 +1,63 @@
# Stage 1: Build the frontend # Stage 1: Build the frontend
FROM cgr.dev/chainguard/node:latest-dev AS node-builder FROM cgr.dev/chainguard/node:latest-dev AS node-builder
USER root
RUN npm install -g pnpm
USER node
WORKDIR /app WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./ COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN npm ci RUN pnpm install --frozen-lockfile
COPY --chown=node:node . . COPY --chown=node:node . .
COPY --chown=node:node svelte.config.docker.js svelte.config.js RUN pnpm run build
RUN npm run build
# Stage 2: Build the Go binary with embedded assets # Stage 2: Build the Go binary with embedded assets
FROM cgr.dev/chainguard/go:latest-dev AS go-builder FROM cgr.dev/chainguard/go:latest-dev AS go-builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . . COPY . .
COPY --from=node-builder /app/build ./build COPY --from=node-builder /app/build ./build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go
# Create data directory for accounts.json and hashes
RUN mkdir -p /app/data && chown 65532:65532 /app/data
# Stage 3: Minimal runtime image # Stage 3: Minimal runtime image
FROM cgr.dev/chainguard/wolfi-base:latest FROM cgr.dev/chainguard/static:latest
WORKDIR /app WORKDIR /app
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION="0.2.0"
LABEL org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.title="Web News" \
org.opencontainers.image.description="A modern, high-performance RSS news reader." \
org.opencontainers.image.url="https://quad4.io" \
org.opencontainers.image.documentation="https://github.com/Quad4-Software/webnews/blob/main/README.md" \
org.opencontainers.image.source="https://github.com/Quad4-Software/webnews" \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.vendor="Quad4" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.authors="Quad4" \
org.opencontainers.image.base.name="cgr.dev/chainguard/static:latest"
COPY --from=go-builder /app/web-news . COPY --from=go-builder /app/web-news .
RUN apk add --no-cache ca-certificates COPY --from=go-builder --chown=65532:65532 /app/data ./data
COPY LICENSE README.md ./
EXPOSE 8080 EXPOSE 8080
ENV PORT=8080 ENV PORT=8080
ENV NODE_ENV=production ENV NODE_ENV=production
ENV AUTH_FILE=/app/data/accounts.json
ENV HASHES_FILE=/app/data/client_hashes.json
ENV RATE_LIMIT=100
ENV RATE_BURST=200
ENV CACHE_FILE=/app/data/cache.db
ENV PUBLIC_INSTANCE=false
USER 65532 USER 65532
CMD ["./web-news"] CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json", "-cache-file", "/app/data/cache.db"]

View File

@@ -10,18 +10,18 @@ help:
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
android-build: build android-build: build
npx cap sync android pnpm cap sync android
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk && cd android && ./gradlew assembleDebug cd android && ./gradlew assembleDebug
mkdir -p $(BUILD_DIR)/android mkdir -p $(BUILD_DIR)/android
cp android/app/build/outputs/apk/debug/app-debug.apk $(BUILD_DIR)/android/web-news-debug.apk cp android/app/build/outputs/apk/debug/app-debug.apk $(BUILD_DIR)/android/web-news-debug.apk
dev: dev:
npm install pnpm install
(command -v air > /dev/null && air || go run main.go & npm run dev) (command -v air > /dev/null && air || go run main.go & pnpm run dev)
frontend-build: frontend-build:
npm install pnpm install
npm run build pnpm run build
build: frontend-build build: frontend-build
mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)
@@ -63,7 +63,11 @@ build-freebsd-amd64:
GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go
docker-build: docker-build:
docker build -t $(BINARY_NAME) . docker build \
--build-arg BUILD_DATE=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=$(shell git rev-parse --short HEAD) \
--build-arg VERSION=$(shell node -p "require('./package.json').version") \
-t $(BINARY_NAME) .
docker-run: docker-run:
docker run -p 8080:8080 $(BINARY_NAME) docker run -p 8080:8080 $(BINARY_NAME)

View File

@@ -25,14 +25,27 @@ Web News follows a "zero-knowledge" philosophy:
3. **Local Cache**: Full-text content is cached locally in IndexedDB for offline reading and instant access. 3. **Local Cache**: Full-text content is cached locally in IndexedDB for offline reading and instant access.
4. **Hardened Backend**: Built-in bot blocking, rate limiting, and secure token generation. 4. **Hardened Backend**: Built-in bot blocking, rate limiting, and secure token generation.
## To-DO
- [ ] Reading time
- [ ] UI/UX Cleanup
- [ ] Use Go Mobile, remove Java RSS plugin.
- [ ] Export article(s)
- [ ] Favicon fetcher and caching
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Go 1.21+ - Go 1.21+
- Node.js 18+ - Node.js 18+
- pnpm 9+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation) (for desktop builds) - [Wails CLI](https://wails.io/docs/gettingstarted/installation) (for desktop builds)
### Build & Run (Web Server) ### Build & Run (Web Server)
Requires Go 1.21+, Node.js 18+, pnpm 9+.
1. **Build the binary**: 1. **Build the binary**:
```bash ```bash
make build make build
@@ -43,6 +56,9 @@ Web News follows a "zero-knowledge" philosophy:
``` ```
### Build & Run (Desktop App) ### Build & Run (Desktop App)
Requires Go 1.25.4+, Wails 2.11.0+, Node.js 18+, pnpm 9+, WebKit2GTK 4.1+ (for Linux).
1. **Launch Dev Mode**: 1. **Launch Dev Mode**:
```bash ```bash
make desktop-dev make desktop-dev
@@ -60,12 +76,14 @@ Web News follows a "zero-knowledge" philosophy:
## Configuration ## Configuration
### Server Flags ### Server Flags
- `--auth-mode`: `none` (default), `token`, or `multi`. - `--auth-mode`: `none` (default), `token`, or `multi`.
- `--allow-registration`: Allow generating new account numbers (default: true). - `--allow-registration`: Allow generating new account numbers (default: true).
- `--auth-file`: Path to the account storage (default: `accounts.json`). - `--auth-file`: Path to the account storage (default: `accounts.json`).
- `--port`: Port to listen on (default: `8080`). - `--port`: Port to listen on (default: `8080`).
### Keyboard Shortcuts (Default) ### Keyboard Shortcuts (Default)
- `j` / `k`: Next / Previous article - `j` / `k`: Next / Previous article
- `r`: Mark as read - `r`: Mark as read
- `s`: Toggle save - `s`: Toggle save
@@ -75,7 +93,7 @@ Web News follows a "zero-knowledge" philosophy:
## Development ## Development
- **Dev Server**: `make dev` (Starts Go backend + Vite frontend) - **Dev Server**: `make dev` (Starts Go backend + Vite frontend)
- **Format & Lint**: `npm run format && npm run lint` - **Format & Lint**: `pnpm run format && pnpm run lint`
- **Clean Artifacts**: `make clean` - **Clean Artifacts**: `make clean`
## License ## License
@@ -83,4 +101,5 @@ Web News follows a "zero-knowledge" philosophy:
MIT - See [LICENSE](LICENSE) for details. MIT - See [LICENSE](LICENSE) for details.
--- ---
Created by [Quad4](https://quad4.io) Created by [Quad4](https://quad4.io)

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.0' classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.google.gms:google-services:4.4.4' classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,9 +1,9 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.quad4.webnews', appId: 'com.quad4.webnews',
appName: 'Web News', appName: 'Web News',
webDir: 'build' webDir: 'build',
}; };
export default config; export default config;

View File

@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
return a.db.SaveArticles(articles) return a.db.SaveArticles(articles)
} }
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) { func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit) a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
if a.db == nil { if a.db == nil {
return "[]", nil return "[]", nil
} }
return a.db.GetArticles(feedId, offset, limit) return a.db.GetArticles(feedId, offset, limit, categoryId)
} }
func (a *App) SearchArticles(query string, limit int) (string, error) { func (a *App) SearchArticles(query string, limit int) (string, error) {

View File

@@ -3,7 +3,7 @@ module git.quad4.io/Quad4-Software/webnews/desktop
go 1.25.4 go 1.25.4
require ( require (
git.quad4.io/Quad4-Software/webnews v0.0.0 git.quad4.io/Quad4-Software/webnews v0.1.0
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
) )
@@ -43,6 +43,7 @@ require (
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect

View File

@@ -1,15 +1,14 @@
{ {
"name": "Web News", "name": "Web News",
"assetdir": "frontend_dist", "assetdir": "frontend_dist",
"frontend:dir": "..", "frontend:dir": "..",
"frontend:install": "npm install", "frontend:install": "pnpm install",
"frontend:build": "npm run build", "frontend:build": "pnpm run build",
"frontend:dev:watcher": "npm run dev", "frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "http://localhost:5173", "frontend:dev:serverUrl": "http://localhost:5173",
"outputfilename": "web-news", "outputfilename": "web-news",
"author": { "author": {
"name": "Quad4", "name": "Quad4",
"email": "dev@quad4.io" "email": "dev@quad4.io"
} }
} }

View File

@@ -0,0 +1,31 @@
services:
web-news:
build:
context: .
dockerfile: Dockerfile
image: ${DOCKER_IMAGE:-web-news:latest}
environment:
- PORT=${PORT:-8080}
- NODE_ENV=production
- AUTH_MODE=${AUTH_MODE:-multi}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
# Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
- AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json
- RATE_LIMIT=${RATE_LIMIT:-100}
- RATE_BURST=${RATE_BURST:-200}
- CACHE_FILE=/app/data/cache.db
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-true}
volumes:
- web-news-data:/app/data
labels:
- 'traefik.enable=true'
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.average=100'
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.burst=50'
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
web-news-data:

23
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,23 @@
# Add your own reverse proxy
services:
web-news:
build: .
container_name: web-news-prod
volumes:
- data:/app/data
environment:
- PORT=8080
- NODE_ENV=production
- AUTH_MODE=multi
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json
- CACHE_FILE=/app/data/cache.db
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-false}
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
data:

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
web-news:
build: .
container_name: web-news
ports:
- '8080:8080'
volumes:
- data:/app/data
environment:
- PORT=8080
- NODE_ENV=production
- AUTH_MODE=none
- AUTH_TOKEN=${AUTH_TOKEN:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json
- CACHE_FILE=/app/data/cache.db
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
data:

View File

@@ -52,6 +52,7 @@ export default [
FileReader: 'readonly', FileReader: 'readonly',
performance: 'readonly', performance: 'readonly',
AbortController: 'readonly', AbortController: 'readonly',
AbortSignal: 'readonly',
DOMParser: 'readonly', DOMParser: 'readonly',
Element: 'readonly', Element: 'readonly',
Node: 'readonly', Node: 'readonly',

1
go.mod
View File

@@ -23,6 +23,7 @@ require (
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"net" "net"
@@ -16,7 +17,9 @@ import (
"time" "time"
"git.quad4.io/Go-Libs/RSS" "git.quad4.io/Go-Libs/RSS"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
readability "github.com/go-shiori/go-readability" readability "github.com/go-shiori/go-readability"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -45,6 +48,71 @@ type Article struct {
ImageURL string `json:"imageUrl"` ImageURL string `json:"imageUrl"`
} }
type cacheEntry struct {
data any
expiresAt time.Time
}
type Cache struct {
entries sync.Map
TTL time.Duration
Enabled bool
Storage *storage.SQLiteDB
}
func (c *Cache) Get(key string) (any, bool) {
if !c.Enabled {
return nil, false
}
if c.Storage != nil {
data, err := c.Storage.GetCache(key)
if err != nil || data == nil {
return nil, false
}
var val any
if err := json.Unmarshal(data, &val); err != nil {
return nil, false
}
return val, true
}
val, ok := c.entries.Load(key)
if !ok {
return nil, false
}
entry := val.(cacheEntry)
if time.Now().After(entry.expiresAt) {
c.entries.Delete(key)
return nil, false
}
return entry.data, true
}
func (c *Cache) Set(key string, data any) {
if !c.Enabled {
return
}
if c.Storage != nil {
b, err := json.Marshal(data)
if err != nil {
return
}
_ = c.Storage.SetCache(key, b, c.TTL)
return
}
c.entries.Store(key, cacheEntry{
data: data,
expiresAt: time.Now().Add(c.TTL),
})
}
var FeedCache = &Cache{TTL: 10 * time.Minute, Enabled: false}
var FullTextCache = &Cache{TTL: 1 * time.Hour, Enabled: false}
var RequestGroup = &singleflight.Group{}
type RateLimiter struct { type RateLimiter struct {
clients map[string]*rate.Limiter clients map[string]*rate.Limiter
mu *sync.RWMutex mu *sync.RWMutex
@@ -94,8 +162,14 @@ func (rl *RateLimiter) SaveHashes() {
} }
rl.mu.RUnlock() rl.mu.RUnlock()
data, _ := json.MarshalIndent(hashes, "", " ") data, err := json.MarshalIndent(hashes, "", " ")
os.WriteFile(rl.File, data, 0600) if err != nil {
log.Printf("Error marshaling rate limit hashes: %v", err)
return
}
if err := os.WriteFile(rl.File, data, 0600); err != nil {
log.Printf("Error writing rate limit hashes to %s: %v", rl.File, err)
}
} }
func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter { func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
@@ -116,7 +190,16 @@ func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
return limiter return limiter
} }
var Limiter = NewRateLimiter(rate.Every(time.Second), 5, "") func (rl *RateLimiter) SetLimit(r rate.Limit, b int) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.r = r
rl.b = b
// Reset existing limiters to apply new rate
rl.clients = make(map[string]*rate.Limiter)
}
var Limiter = NewRateLimiter(rate.Limit(50), 100, "")
var ForbiddenPatterns = []string{ var ForbiddenPatterns = []string{
".git", ".env", ".aws", ".config", ".ssh", ".git", ".env", ".aws", ".config", ".ssh",
@@ -124,14 +207,34 @@ var ForbiddenPatterns = []string{
"etc/passwd", "cgi-bin", "etc/passwd", "cgi-bin",
} }
func GetRealIP(r *http.Request) string {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
return strings.TrimSpace(xff[:comma])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return ip
}
func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc { func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
path := strings.ToLower(r.URL.Path) path := strings.ToLower(r.URL.Path)
query := strings.ToLower(r.URL.RawQuery)
for _, pattern := range ForbiddenPatterns { for _, pattern := range ForbiddenPatterns {
if strings.Contains(path, pattern) || strings.Contains(query, pattern) { if strings.Contains(path, pattern) {
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr) ip := GetRealIP(r)
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), ip)
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@@ -270,18 +373,7 @@ func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc { func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr) ip := GetRealIP(r)
if err != nil {
ip = r.RemoteAddr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
ip = xff[:comma]
} else {
ip = xff
}
}
ua := r.Header.Get("User-Agent") ua := r.Header.Get("User-Agent")
hash := sha256.New() hash := sha256.New()
@@ -310,94 +402,111 @@ func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
client := &http.Client{Timeout: 15 * time.Second} if data, ok := FeedCache.Get(feedURL); ok {
req, err := http.NewRequest("GET", feedURL, nil) w.Header().Set("Content-Type", "application/json")
if err != nil { if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError) log.Printf("Error encoding cached feed proxy response: %v", err)
}
return return
} }
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection val, err, _ := RequestGroup.Do(feedURL, func() (any, error) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") client := &http.Client{Timeout: 15 * time.Second}
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7") req, err := http.NewRequest("GET", feedURL, nil)
req.Header.Set("Cache-Control", "no-cache") if err != nil {
req.Header.Set("Pragma", "no-cache") return nil, fmt.Errorf("failed to create request: %w", err)
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch feed: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "Feed returned status "+resp.Status, http.StatusBadGateway)
return
}
data, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Failed to read feed body", http.StatusInternalServerError)
return
}
parsedFeed, err := rss.Parse(data)
if err != nil {
http.Error(w, "Failed to parse feed: "+err.Error(), http.StatusInternalServerError)
return
}
articles := make([]Article, 0, len(parsedFeed.Items))
for _, item := range parsedFeed.Items {
id := item.GUID
if id == "" {
id = item.Link
} }
pubDate := time.Now().UnixMilli() // Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
if item.Published != nil { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
pubDate = item.Published.UnixMilli() req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed returned status %s", resp.Status)
} }
author := "" data, err := io.ReadAll(resp.Body)
if item.Author != nil { if err != nil {
author = item.Author.Name return nil, fmt.Errorf("failed to read feed body: %w", err)
} }
imageURL := "" parsedFeed, err := rss.Parse(data)
for _, enc := range item.Enclosures { if err != nil {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" { return nil, fmt.Errorf("failed to parse feed: %w", err)
imageURL = enc.URL }
break
articles := make([]Article, 0, len(parsedFeed.Items))
for _, item := range parsedFeed.Items {
id := item.GUID
if id == "" {
id = item.Link
} }
pubDate := time.Now().UnixMilli()
if item.Published != nil {
pubDate = item.Published.UnixMilli()
}
author := ""
if item.Author != nil {
author = item.Author.Name
}
imageURL := ""
for _, enc := range item.Enclosures {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
imageURL = enc.URL
break
}
}
articles = append(articles, Article{
ID: id,
FeedID: feedURL,
Title: item.Title,
Link: item.Link,
Description: item.Description,
Author: author,
PubDate: pubDate,
Read: false,
Saved: false,
ImageURL: imageURL,
})
} }
articles = append(articles, Article{ response := ProxyResponse{
ID: id, Feed: FeedInfo{
FeedID: feedURL, Title: parsedFeed.Title,
Title: item.Title, SiteURL: parsedFeed.Link,
Link: item.Link, Description: parsedFeed.Description,
Description: item.Description, LastFetched: time.Now().UnixMilli(),
Author: author, },
PubDate: pubDate, Articles: articles,
Read: false, }
Saved: false,
ImageURL: imageURL,
})
}
response := ProxyResponse{ FeedCache.Set(feedURL, response)
Feed: FeedInfo{ return response, nil
Title: parsedFeed.Title, })
SiteURL: parsedFeed.Link,
Description: parsedFeed.Description, if err != nil {
LastFetched: time.Now().UnixMilli(), if strings.Contains(err.Error(), "status") {
}, http.Error(w, err.Error(), http.StatusBadGateway)
Articles: articles, } else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Error encoding feed proxy response: %v", err) log.Printf("Error encoding feed proxy response: %v", err)
} }
} }
@@ -467,46 +576,61 @@ func HandleFullText(w http.ResponseWriter, r *http.Request) {
return return
} }
parsedURL, _ := url.Parse(targetURL) if data, ok := FullTextCache.Get(targetURL); ok {
article, err := readability.FromURL(targetURL, 15*time.Second) w.Header().Set("Content-Type", "application/json")
if err != nil { if err := json.NewEncoder(w).Encode(data); err != nil {
client := &http.Client{Timeout: 15 * time.Second} log.Printf("Error encoding cached fulltext response: %v", err)
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch content: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
article, err = readability.FromReader(resp.Body, parsedURL)
if err != nil {
http.Error(w, "Failed to extract content: "+err.Error(), http.StatusInternalServerError)
return
} }
return
} }
response := FullTextResponse{ val, err, _ := RequestGroup.Do("ft-"+targetURL, func() (any, error) {
Title: article.Title, parsedURL, _ := url.Parse(targetURL)
Content: article.Content, article, err := readability.FromURL(targetURL, 15*time.Second)
TextContent: article.TextContent, if err != nil {
Excerpt: article.Excerpt, client := &http.Client{Timeout: 15 * time.Second}
Byline: article.Byline, req, err := http.NewRequest("GET", targetURL, nil)
SiteName: article.SiteName, if err != nil {
Image: article.Image, return nil, fmt.Errorf("failed to create request: %w", err)
Favicon: article.Favicon, }
URL: targetURL, req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch content: %w", err)
}
defer resp.Body.Close()
article, err = readability.FromReader(resp.Body, parsedURL)
if err != nil {
return nil, fmt.Errorf("failed to extract content: %w", err)
}
}
response := FullTextResponse{
Title: article.Title,
Content: article.Content,
TextContent: article.TextContent,
Excerpt: article.Excerpt,
Byline: article.Byline,
SiteName: article.SiteName,
Image: article.Image,
Favicon: article.Favicon,
URL: targetURL,
}
FullTextCache.Set(targetURL, response)
return response, nil
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Error encoding fulltext response: %v", err) log.Printf("Error encoding fulltext response: %v", err)
} }
} }

View File

@@ -87,6 +87,12 @@ func (s *SQLiteDB) init() error {
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
);`, );`,
`CREATE TABLE IF NOT EXISTS caches (
key TEXT PRIMARY KEY,
value BLOB,
expiresAt INTEGER
);`,
`CREATE INDEX IF NOT EXISTS idx_caches_expiresAt ON caches(expiresAt);`,
} }
for _, q := range queries { for _, q := range queries {
@@ -316,15 +322,22 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
return tx.Commit() return tx.Commit()
} }
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) { func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
if feedId != "" { if feedId != "" {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset) rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
} else if categoryId != "" {
rows, err = s.db.Query(`
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
} else { } else {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset) rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
} }
if err != nil { if err != nil {
return "[]", err return "[]", err
} }
@@ -371,7 +384,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
FROM articles FROM articles
WHERE title LIKE ? OR description LIKE ? OR content LIKE ? WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit) ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
if err != nil { if err != nil {
return "[]", err return "[]", err
} }
@@ -482,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) { func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli() cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles FROM articles
WHERE read = 1 AND readAt > ? WHERE read = 1 AND readAt > ?
GROUP BY date GROUP BY date
@@ -499,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
if err := rows.Scan(&date, &count); err != nil { if err := rows.Scan(&date, &count); err != nil {
continue continue
} }
// Convert date string back to timestamp for frontend // Convert local date string back to local midnight timestamp for frontend
t, _ := time.Parse("2006-01-02", date) t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
continue
}
history = append(history, map[string]any{ history = append(history, map[string]any{
"date": t.UnixMilli(), "date": t.UnixMilli(),
"count": count, "count": count,
@@ -510,3 +526,32 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
b, _ := json.Marshal(history) b, _ := json.Marshal(history)
return string(b), nil return string(b), nil
} }
func (s *SQLiteDB) SetCache(key string, value []byte, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := s.db.Exec("INSERT OR REPLACE INTO caches (key, value, expiresAt) VALUES (?, ?, ?)", key, value, expiresAt)
return err
}
func (s *SQLiteDB) GetCache(key string) ([]byte, error) {
var value []byte
var expiresAt int64
err := s.db.QueryRow("SELECT value, expiresAt FROM caches WHERE key = ?", key).Scan(&value, &expiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if time.Now().UnixMilli() > expiresAt {
_, _ = s.db.Exec("DELETE FROM caches WHERE key = ?", key)
return nil, nil
}
return value, nil
}
func (s *SQLiteDB) PurgeExpiredCaches() error {
now := time.Now().UnixMilli()
_, err := s.db.Exec("DELETE FROM caches WHERE expiresAt < ?", now)
return err
}

99
main.go
View File

@@ -4,6 +4,7 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt"
"io/fs" "io/fs"
"log" "log"
"net" "net"
@@ -13,6 +14,8 @@ import (
"time" "time"
"git.quad4.io/Quad4-Software/webnews/internal/api" "git.quad4.io/Quad4-Software/webnews/internal/api"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
"golang.org/x/time/rate"
) )
//go:embed build/* //go:embed build/*
@@ -72,15 +75,101 @@ func main() {
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins") allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
// Auth flags // Auth flags
authMode := flag.String("auth-mode", "none", "Authentication mode: none, token, multi") defaultAuthMode := os.Getenv("AUTH_MODE")
if defaultAuthMode == "" {
defaultAuthMode = "none"
}
authMode := flag.String("auth-mode", defaultAuthMode, "Authentication mode: none, token, multi")
authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode") authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode")
authFile := flag.String("auth-file", "accounts.json", "File to store accounts for 'multi' auth mode")
allowReg := flag.Bool("allow-registration", true, "Allow new account generation in 'multi' mode") defaultAuthFile := os.Getenv("AUTH_FILE")
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting") if defaultAuthFile == "" {
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection") defaultAuthFile = "accounts.json"
}
authFile := flag.String("auth-file", defaultAuthFile, "File to store accounts for 'multi' auth mode")
defaultAllowReg := true
if os.Getenv("ALLOW_REGISTRATION") == "false" {
defaultAllowReg = false
}
allowReg := flag.Bool("allow-registration", defaultAllowReg, "Allow new account generation in 'multi' mode")
defaultHashesFile := os.Getenv("HASHES_FILE")
if defaultHashesFile == "" {
defaultHashesFile = "client_hashes.json"
}
hashesFile := flag.String("hashes-file", defaultHashesFile, "File to store IP+UA hashes for rate limiting")
rateLimit := flag.Float64("rate-limit", 50.0, "Rate limit in requests per second (env: RATE_LIMIT)")
rateBurst := flag.Int("rate-burst", 100, "Rate limit burst size (env: RATE_BURST)")
disableProtection := flag.Bool("disable-protection", os.Getenv("DISABLE_PROTECTION") == "true", "Disable rate limiting and bot protection")
publicInstance := flag.Bool("public-instance", os.Getenv("PUBLIC_INSTANCE") == "true", "Enable optimizations for public instances (caching, etc.)")
cacheEnabled := flag.Bool("cache-enabled", os.Getenv("CACHE_ENABLED") == "true", "Explicitly enable/disable caching")
cacheTTL := flag.Duration("cache-ttl", 10*time.Minute, "Cache TTL (env: CACHE_TTL)")
cacheFile := flag.String("cache-file", os.Getenv("CACHE_FILE"), "SQLite file for caching (reduces memory load)")
flag.Parse() flag.Parse()
// Handle cache config
if envTTL := os.Getenv("CACHE_TTL"); envTTL != "" {
if d, err := time.ParseDuration(envTTL); err == nil {
*cacheTTL = d
}
}
api.FeedCache.TTL = *cacheTTL
api.FullTextCache.TTL = *cacheTTL * 6 // Full text stays longer
if *cacheFile != "" {
db, err := storage.NewSQLiteDB(*cacheFile)
if err != nil {
log.Fatalf("Failed to initialize cache database: %v", err)
}
api.FeedCache.Storage = db
api.FullTextCache.Storage = db
log.Printf("Using SQLite for caching: %s\n", *cacheFile)
// Background cleanup of expired items
go func() {
for {
time.Sleep(1 * time.Hour)
if err := db.PurgeExpiredCaches(); err != nil {
log.Printf("Error purging expired caches: %v", err)
}
}
}()
}
if *publicInstance {
api.FeedCache.Enabled = true
api.FullTextCache.Enabled = true
log.Printf("Public instance optimizations enabled (caching enabled, TTL: %v)\n", *cacheTTL)
}
if os.Getenv("CACHE_ENABLED") != "" {
api.FeedCache.Enabled = *cacheEnabled
api.FullTextCache.Enabled = *cacheEnabled
log.Printf("Caching explicitly %v (TTL: %v)\n", map[bool]string{true: "enabled", false: "disabled"}[*cacheEnabled], *cacheTTL)
}
// Override rate limits from environment if set
if envRate := os.Getenv("RATE_LIMIT"); envRate != "" {
var r float64
if _, err := fmt.Sscanf(envRate, "%f", &r); err == nil {
*rateLimit = r
}
}
if envBurst := os.Getenv("RATE_BURST"); envBurst != "" {
var b int
if _, err := fmt.Sscanf(envBurst, "%d", &b); err == nil {
*rateBurst = b
}
}
api.Limiter.SetLimit(rate.Limit(*rateLimit), *rateBurst)
if *hashesFile != "" { if *hashesFile != "" {
api.Limiter.File = *hashesFile api.Limiter.File = *hashesFile
api.Limiter.LoadHashes() api.Limiter.LoadHashes()

5462
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "web-news", "name": "web-news",
"version": "0.1.0", "version": "0.2.3",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
"bin": { "bin": {
@@ -26,14 +26,14 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint .", "lint": "eslint .",
"package": "npm run build" "package": "pnpm run build"
}, },
"devDependencies": { "devDependencies": {
"@capacitor-community/sqlite": "^7.0.2", "@capacitor-community/sqlite": "^7.0.2",
"@capacitor/cli": "^8.0.0", "@capacitor/cli": "^8.0.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -42,11 +42,11 @@
"eslint-plugin-svelte": "^3.13.1", "eslint-plugin-svelte": "^3.13.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6", "svelte": "^5.46.1",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"svelte-eslint-parser": "^1.4.1", "svelte-eslint-parser": "^1.4.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.3.0"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^8.0.0", "@capacitor/android": "^8.0.0",
@@ -55,5 +55,8 @@
"lucide-svelte": "^0.562.0", "lucide-svelte": "^0.562.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19" "tailwindcss": "^3.4.19"
},
"overrides": {
"cookie": "^1.0.0"
} }
} }

3419
pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -2,5 +2,5 @@
set -euo pipefail set -euo pipefail
echo "Building app..." echo "Building app..."
VITE_APP_VERSION=$(node -p "require('./package.json').version") npm run build VITE_APP_VERSION=$(node -p "require('./package.json').version") pnpm run build

View File

@@ -2,8 +2,8 @@
set -euo pipefail set -euo pipefail
echo "Running Svelte sync..." echo "Running Svelte sync..."
npx svelte-kit sync pnpm svelte-kit sync
echo "Running svelte-check (fail on errors)..." echo "Running svelte-check (fail on errors)..."
npx svelte-check --tsconfig ./tsconfig.json pnpm svelte-check --tsconfig ./tsconfig.json

View File

@@ -21,4 +21,3 @@ swContent = swContent.replace(
writeFileSync(swPath, swContent); writeFileSync(swPath, swContent);
console.log(`Injected version ${version} into service worker`); console.log(`Injected version ${version} into service worker`);

View File

@@ -23,20 +23,16 @@ VULNS=$(jq -r '
.results[]? | .results[]? |
.source as $src | .source as $src |
.vulns[]? | .vulns[]? |
select(
(.database_specific.severity // "" | ascii_upcase | test("HIGH|CRITICAL")) or
(.severity[]?.score // "" | tostring | split("/")[0] | tonumber? // 0 | . >= 7.0)
) |
"\(.id) (source: \($src))" "\(.id) (source: \($src))"
' "$OSV_JSON") ' "$OSV_JSON")
if [ -n "$VULNS" ]; then if [ -n "$VULNS" ]; then
echo "OSV scan found HIGH/CRITICAL vulnerabilities:" echo "OSV scan found vulnerabilities:"
echo "$VULNS" | while IFS= read -r line; do echo "$VULNS" | while IFS= read -r line; do
echo " - $line" echo " - $line"
done done
exit 1 exit 1
else else
echo "OSV scan: no HIGH/CRITICAL vulnerabilities found." echo "OSV scan: no vulnerabilities found."
fi fi

View File

@@ -26,6 +26,10 @@ elif [ -f "desktop/build/bin/web-news" ]; then
cp desktop/build/bin/web-news dist/web-news-desktop-darwin cp desktop/build/bin/web-news dist/web-news-desktop-darwin
fi fi
echo "Building Android APK..."
make android-build
cp bin/android/web-news-debug.apk dist/web-news-android-debug.apk
echo "Generating SHA256 hashes..." echo "Generating SHA256 hashes..."
cd dist cd dist
sha256sum * > SHA256SUMS sha256sum * > SHA256SUMS

View File

@@ -6,7 +6,8 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64 zip sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64 zip
echo "Installing project dependencies..." echo "Installing project dependencies..."
npm ci npm install -g pnpm
pnpm install --frozen-lockfile
echo "Installing Wails CLI..." echo "Installing Wails CLI..."
go install github.com/wailsapp/wails/v2/cmd/wails@latest go install github.com/wailsapp/wails/v2/cmd/wails@latest

View File

@@ -53,7 +53,7 @@
.card { .card {
@apply bg-bg-primary border border-border-color rounded-lg overflow-hidden transition-all hover:shadow-md; @apply bg-bg-primary border border-border-color rounded-lg overflow-hidden transition-all hover:shadow-md;
} }
.btn-primary { .btn-primary {
@apply bg-accent-blue text-white px-4 py-2 rounded-md font-medium hover:bg-accent-blue-dark transition-colors; @apply bg-accent-blue text-white px-4 py-2 rounded-md font-medium hover:bg-accent-blue-dark transition-colors;
} }

View File

@@ -3,13 +3,39 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Web News - Personal RSS Reader</title>
<meta name="description" content="A fast, clean, and private RSS reader for all your news." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="shortcut icon" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://webnews.quad4.io" />
<meta property="og:title" content="Web News" />
<meta
property="og:description"
content="A fast, clean, and private RSS reader for all your news."
/>
<meta property="og:image" content="/favicon.svg" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://webnews.quad4.io" />
<meta property="twitter:title" content="Web News" />
<meta
property="twitter:description"
content="A fast, clean, and private RSS reader for all your news."
/>
<meta property="twitter:image" content="/favicon.svg" />
<meta name="theme-color" content="#1a73e8" /> <meta name="theme-color" content="#1a73e8" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Web News" /> <meta name="apple-mobile-web-app-title" content="Web News" />
<link rel="apple-touch-icon" href="/favicon.svg" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { db } from '$lib/db'; import { db } from '$lib/db';
import { fetchFeed } from '$lib/rss'; import { fetchFeed } from '$lib/rss';
import { parseOPML } from '$lib/opml';
import { newsStore } from '$lib/store.svelte'; import { newsStore } from '$lib/store.svelte';
import { X, Loader2 } from 'lucide-svelte'; import { X, Loader2, Upload } from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
let { onOpenChange } = $props(); let { onOpenChange } = $props();
let feedUrl = $state(''); let feedUrl = $state('');
@@ -26,7 +28,7 @@
lastFetched: Date.now(), lastFetched: Date.now(),
fetchInterval: 30, fetchInterval: 30,
enabled: true, enabled: true,
consecutiveErrors: 0 consecutiveErrors: 0,
}); });
await db.saveArticles(articles); await db.saveArticles(articles);
await newsStore.refresh(); await newsStore.refresh();
@@ -37,29 +39,70 @@
loading = false; loading = false;
} }
} }
async function handleImport(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
loading = true;
error = '';
try {
const text = await file.text();
const { feeds, categories } = parseOPML(text);
if (categories.length > 0) {
await db.saveCategories(categories as any);
}
if (feeds.length > 0) {
await db.saveFeeds(feeds as any);
}
toast.success(`Imported ${feeds.length} feeds`);
await newsStore.init();
onOpenChange(false);
} catch (err) {
console.error('Import failed:', err);
error = 'Failed to import OPML';
} finally {
loading = false;
target.value = '';
}
}
</script> </script>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<button <button
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default" class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
onclick={() => onOpenChange(false)} onclick={() => onOpenChange(false)}
aria-label="Close modal" aria-label="Close modal"
></button> ></button>
<div class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10"> <div
class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10"
>
<div class="p-6 border-b border-border-color flex justify-between items-center"> <div class="p-6 border-b border-border-color flex justify-between items-center">
<h2 class="text-xl font-bold">Add RSS Feed</h2> <h2 class="text-xl font-bold">Add RSS Feed</h2>
<button class="text-text-secondary hover:text-text-primary" onclick={() => onOpenChange(false)}> <button
class="text-text-secondary hover:text-text-primary"
onclick={() => onOpenChange(false)}
>
<X size={24} /> <X size={24} />
</button> </button>
</div> </div>
<form class="p-6 space-y-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> <form
class="p-6 space-y-4"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="space-y-2"> <div class="space-y-2">
<label for="url" class="text-sm font-medium text-text-secondary">Feed URL</label> <label for="url" class="text-sm font-medium text-text-secondary">Feed URL</label>
<input <input
id="url" id="url"
type="url" type="url"
bind:value={feedUrl} bind:value={feedUrl}
placeholder="https://example.com/rss.xml" placeholder="https://example.com/rss.xml"
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all" class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all"
@@ -69,7 +112,7 @@
<div class="space-y-2"> <div class="space-y-2">
<label for="category" class="text-sm font-medium text-text-secondary">Category</label> <label for="category" class="text-sm font-medium text-text-secondary">Category</label>
<select <select
id="category" id="category"
bind:value={categoryId} bind:value={categoryId}
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all text-sm" class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all text-sm"
@@ -84,8 +127,8 @@
<p class="text-red-500 text-sm">{error}</p> <p class="text-red-500 text-sm">{error}</p>
{/if} {/if}
<button <button
type="submit" type="submit"
class="w-full btn-primary flex items-center justify-center gap-2 py-3" class="w-full btn-primary flex items-center justify-center gap-2 py-3"
disabled={loading} disabled={loading}
> >
@@ -96,7 +139,30 @@
Add Feed Add Feed
{/if} {/if}
</button> </button>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border-color"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
</div>
</div>
<label
class="w-full flex items-center justify-center gap-2 py-3 bg-bg-secondary border border-border-color rounded-xl text-sm font-semibold text-text-primary hover:bg-bg-primary transition-all cursor-pointer {loading
? 'opacity-50 pointer-events-none'
: ''}"
>
{#if loading}
<Loader2 size={18} class="animate-spin" />
Importing...
{:else}
<Upload size={18} />
Import OPML File
{/if}
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte'; import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
let { article }: { article: Article } = $props(); let { article }: { article: Article } = $props();
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
let copied = $state(false); let copied = $state(false);
let loadingFullText = $state(false); let loadingFullText = $state(false);
@@ -12,27 +13,29 @@
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
const diff = now.getTime() - date.getTime(); const diff = now.getTime() - date.getTime();
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function getSource(feedId: string) { function getSourceTitle() {
const feed = newsStore.feeds.find(f => f.id === feedId); return (
return feed?.title || new URL(feedId).hostname; feed?.title ||
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
);
} }
async function shareArticle(e: MouseEvent) { async function shareArticle(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const encodedUrl = btoa(article.link); const encodedUrl = btoa(article.link);
const shareUrl = `${window.location.origin}/share?url=${encodedUrl}`; const shareUrl = `${window.location.origin}/share?url=${encodedUrl}`;
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
copied = true; copied = true;
toast.success('Share link copied to clipboard'); toast.success('Share link copied to clipboard');
setTimeout(() => copied = false, 2000); setTimeout(() => (copied = false), 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy share link:', err); console.error('Failed to copy share link:', err);
toast.error('Failed to copy share link'); toast.error('Failed to copy share link');
@@ -71,26 +74,56 @@
} }
</script> </script>
<article class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read ? 'opacity-60' : ''} {newsStore.readingArticle?.url === article.link ? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5' : ''}"> <article
class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read
? 'opacity-60'
: ''} {newsStore.readingArticle?.url === article.link
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
: ''}"
>
<!-- Background Flavor Layer -->
{#if article.imageUrl || feed?.icon}
<div class="absolute inset-0 z-0 overflow-hidden rounded-2xl pointer-events-none">
{#if article.imageUrl && article.imageUrl !== feed?.icon}
<div
class="absolute right-0 top-0 bottom-0 w-full sm:w-3/4 overflow-hidden"
style="mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);"
>
<img
src={article.imageUrl}
alt=""
class="w-full h-full object-cover opacity-[0.22] dark:opacity-[0.10] group-hover:opacity-[0.32] dark:group-hover:opacity-[0.18] transition-all duration-700 group-hover:scale-110 origin-right"
/>
</div>
{:else if feed?.icon}
<div
class="absolute inset-0 opacity-0 group-hover:opacity-[0.08] dark:group-hover:opacity-[0.05] transition-opacity duration-500"
>
<img src={feed.icon} alt="" class="w-full h-full object-cover blur-3xl scale-150" />
</div>
{/if}
</div>
{/if}
{#if newsStore.isSelectMode} {#if newsStore.isSelectMode}
<div class="flex items-center pl-4 z-20"> <div class="flex items-center pl-4 z-20">
<div class="relative w-5 h-5"> <div class="relative w-5 h-5">
<input <input
type="checkbox" type="checkbox"
class="peer appearance-none w-5 h-5 rounded border-2 border-border-color checked:bg-accent-blue checked:border-accent-blue transition-all cursor-pointer" class="peer appearance-none w-5 h-5 rounded border-2 border-border-color checked:bg-accent-blue checked:border-accent-blue transition-all cursor-pointer"
checked={newsStore.selectedArticleIds.has(article.id)} checked={newsStore.selectedArticleIds.has(article.id)}
onchange={handleToggleSelect} onchange={handleToggleSelect}
/> />
<Check <Check
size={14} size={14}
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
strokeWidth={3} strokeWidth={3}
/> />
</div> </div>
</div> </div>
{/if} {/if}
<button <button
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0" class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
onclick={() => { onclick={() => {
if (newsStore.isSelectMode) { if (newsStore.isSelectMode) {
const isSelected = newsStore.selectedArticleIds.has(article.id); const isSelected = newsStore.selectedArticleIds.has(article.id);
@@ -106,21 +139,28 @@
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none"> <div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto">{getSource(article.feedId)}</span> {#if feed?.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{/if}
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
>{getSourceTitle()}</span
>
<span class="text-text-secondary text-xs">•</span> <span class="text-text-secondary text-xs">•</span>
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span> <span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
</div> </div>
<h3 class="text-lg font-bold leading-snug mb-2 group-hover:text-accent-blue transition-colors"> <h3 class="text-lg font-bold leading-snug mb-2 group-hover:text-accent-blue transition-colors">
{article.title} {article.title}
</h3> </h3>
<p class="text-text-secondary text-sm line-clamp-2 mb-4"> <p class="text-text-secondary text-sm line-clamp-2 mb-4">
{article.description} {article.description}
</p> </p>
<div class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"> <div
<button class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
>
<button
class="flex items-center gap-1.5 px-3 py-1 rounded-full hover:bg-bg-secondary transition-colors text-xs font-semibold hover:text-accent-blue" class="flex items-center gap-1.5 px-3 py-1 rounded-full hover:bg-bg-secondary transition-colors text-xs font-semibold hover:text-accent-blue"
onclick={fetchFullText} onclick={fetchFullText}
disabled={loadingFullText} disabled={loadingFullText}
@@ -132,15 +172,17 @@
{/if} {/if}
Read Read
</button> </button>
<button <button
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved ? 'text-accent-blue' : 'hover:text-text-primary'}" class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved
? 'text-accent-blue'
: 'hover:text-text-primary'}"
title={article.saved ? 'Remove from saved' : 'Save for later'} title={article.saved ? 'Remove from saved' : 'Save for later'}
onclick={toggleSave} onclick={toggleSave}
> >
<Bookmark size={18} fill={article.saved ? 'currentColor' : 'none'} /> <Bookmark size={18} fill={article.saved ? 'currentColor' : 'none'} />
</button> </button>
<button <button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1" class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
title="Copy share link" title="Copy share link"
onclick={shareArticle} onclick={shareArticle}
> >
@@ -150,19 +192,16 @@
<Share2 size={18} /> <Share2 size={18} />
{/if} {/if}
</button> </button>
<button <button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors" class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
title="Open in new tab" title="Open in new tab"
onclick={(e) => { e.stopPropagation(); window.open(article.link, '_blank'); }} onclick={(e) => {
e.stopPropagation();
window.open(article.link, '_blank');
}}
> >
<MoreVertical size={18} /> <MoreVertical size={18} />
</button> </button>
</div> </div>
</div> </div>
{#if article.imageUrl}
<div class="w-full sm:w-32 h-48 sm:h-32 flex-shrink-0 sm:m-4 rounded-xl overflow-hidden bg-bg-secondary border border-border-color relative z-10 pointer-events-none">
<img src={article.imageUrl} alt="" class="w-full h-full object-cover transition-transform group-hover:scale-105" />
</div>
{/if}
</article> </article>

View File

@@ -5,17 +5,35 @@
let { onAddFeed } = $props(); let { onAddFeed } = $props();
</script> </script>
<header class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe"> <header
class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary" class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
aria-label="Menu" aria-label="Menu"
onclick={() => newsStore.showSidebar = !newsStore.showSidebar} onclick={() => (newsStore.showSidebar = !newsStore.showSidebar)}
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg> <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line
x1="3"
y1="18"
x2="21"
y2="18"
></line></svg
>
</button> </button>
<button <button
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1" class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
onclick={() => { onclick={() => {
newsStore.selectFeed(null); newsStore.selectFeed(null);
newsStore.currentView = 'all'; newsStore.currentView = 'all';
@@ -24,7 +42,22 @@
aria-label="Web News Home" aria-label="Web News Home"
> >
<div class="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center text-white"> <div class="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg> <svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle
cx="5"
cy="19"
r="1"
></circle></svg
>
</div> </div>
<h1 class="text-xl font-bold tracking-tight hidden sm:block">Web News</h1> <h1 class="text-xl font-bold tracking-tight hidden sm:block">Web News</h1>
</button> </button>
@@ -33,9 +66,9 @@
<div class="flex-1 max-w-2xl mx-4 hidden sm:block"> <div class="flex-1 max-w-2xl mx-4 hidden sm:block">
<div class="relative"> <div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} /> <Search class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
<input <input
type="text" type="text"
placeholder="Search for topics, locations & sources" placeholder="Search for topics, locations & sources"
class="w-full bg-bg-secondary border-none rounded-xl py-2.5 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all" class="w-full bg-bg-secondary border-none rounded-xl py-2.5 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all"
bind:value={newsStore.searchQuery} bind:value={newsStore.searchQuery}
oninput={() => newsStore.loadArticles()} oninput={() => newsStore.loadArticles()}
@@ -45,32 +78,42 @@
<div class="flex items-center gap-1 sm:gap-2"> <div class="flex items-center gap-1 sm:gap-2">
{#if newsStore.ping !== null && !newsStore.isWails && !newsStore.isCapacitor} {#if newsStore.ping !== null && !newsStore.isWails && !newsStore.isCapacitor}
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color"> <div
<div class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200 ? 'bg-green-500' : newsStore.ping < 500 ? 'bg-yellow-500' : 'bg-red-500'}"></div> class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color"
>
<div
class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200
? 'bg-green-500'
: newsStore.ping < 500
? 'bg-yellow-500'
: 'bg-red-500'}"
></div>
<span class="text-[10px] font-medium text-text-secondary">{newsStore.ping}ms</span> <span class="text-[10px] font-medium text-text-secondary">{newsStore.ping}ms</span>
</div> </div>
{:else if !newsStore.isOnline} {:else if !newsStore.isOnline}
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20"> <div
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20"
>
<div class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></div> <div class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-[10px] font-medium text-red-500">Offline</span> <span class="text-[10px] font-medium text-red-500">Offline</span>
</div> </div>
{/if} {/if}
<button <button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors" class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={() => newsStore.refresh()} onclick={() => newsStore.refresh()}
title="Refresh feeds" title="Refresh feeds"
> >
<RefreshCw size={20} class={newsStore.loading ? 'animate-spin text-accent-blue' : ''} /> <RefreshCw size={20} class={newsStore.loading ? 'animate-spin text-accent-blue' : ''} />
</button> </button>
<button <button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors" class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={onAddFeed} onclick={onAddFeed}
title="Add RSS Feed" title="Add RSS Feed"
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
<button <button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors" class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={() => newsStore.toggleTheme()} onclick={() => newsStore.toggleTheme()}
title="Toggle theme" title="Toggle theme"
@@ -83,4 +126,3 @@
</button> </button>
</div> </div>
</header> </header>

View File

@@ -2,17 +2,37 @@
import { newsStore } from '$lib/store.svelte'; import { newsStore } from '$lib/store.svelte';
import { db } from '$lib/db'; import { db } from '$lib/db';
import { exportToOPML, parseOPML } from '$lib/opml'; import { exportToOPML, parseOPML } from '$lib/opml';
import { Home, Star, Bookmark, Hash, Settings as SettingsIcon, ChevronRight, ChevronDown, AlertCircle, Edit2, GripVertical, Plus, Trash2, Save, X, Download, Upload, GitBranch } from 'lucide-svelte'; import {
Home,
Star,
Bookmark,
Hash,
Settings as SettingsIcon,
ChevronRight,
ChevronDown,
AlertCircle,
Edit2,
GripVertical,
Plus,
Trash2,
Save,
X,
Download,
Upload,
GitBranch,
RefreshCw,
} from 'lucide-svelte';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { APP_VERSION } from '$lib/version';
let { onOpenSettings } = $props(); let { onOpenSettings } = $props();
let expandedCategories = $state<Record<string, boolean>>({}); let expandedCategories = $state<Record<string, boolean>>({});
$effect(() => { $effect(() => {
// Expand new categories by default // Expand new categories by default
newsStore.categories.forEach(cat => { newsStore.categories.forEach((cat) => {
if (expandedCategories[cat.id] === undefined) { if (expandedCategories[cat.id] === undefined) {
expandedCategories[cat.id] = true; expandedCategories[cat.id] = true;
} }
@@ -38,7 +58,7 @@
function getFeedsForCategory(categoryId: string) { function getFeedsForCategory(categoryId: string) {
return newsStore.feeds return newsStore.feeds
.filter(f => f.categoryId === categoryId) .filter((f) => f.categoryId === categoryId)
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
} }
@@ -66,34 +86,34 @@
if (draggedCategoryId && targetType === 'category') { if (draggedCategoryId && targetType === 'category') {
const cats = [...newsStore.categories].sort((a, b) => a.order - b.order); const cats = [...newsStore.categories].sort((a, b) => a.order - b.order);
const fromIndex = cats.findIndex(c => c.id === draggedCategoryId); const fromIndex = cats.findIndex((c) => c.id === draggedCategoryId);
const toIndex = cats.findIndex(c => c.id === targetId); const toIndex = cats.findIndex((c) => c.id === targetId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
const [moved] = cats.splice(fromIndex, 1); const [moved] = cats.splice(fromIndex, 1);
cats.splice(toIndex, 0, moved); cats.splice(toIndex, 0, moved);
await newsStore.reorderCategories(cats.map(c => c.id)); await newsStore.reorderCategories(cats.map((c) => c.id));
} }
} else if (draggedFeedId && targetType === 'feed') { } else if (draggedFeedId && targetType === 'feed') {
const sourceFeed = newsStore.feeds.find(f => f.id === draggedFeedId); const sourceFeed = newsStore.feeds.find((f) => f.id === draggedFeedId);
const targetFeed = newsStore.feeds.find(f => f.id === targetId); const targetFeed = newsStore.feeds.find((f) => f.id === targetId);
if (sourceFeed && targetFeed && sourceFeed.categoryId === targetFeed.categoryId) { if (sourceFeed && targetFeed && sourceFeed.categoryId === targetFeed.categoryId) {
const catFeeds = newsStore.feeds const catFeeds = newsStore.feeds
.filter(f => f.categoryId === sourceFeed.categoryId) .filter((f) => f.categoryId === sourceFeed.categoryId)
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
const fromIndex = catFeeds.findIndex(f => f.id === draggedFeedId); const fromIndex = catFeeds.findIndex((f) => f.id === draggedFeedId);
const toIndex = catFeeds.findIndex(f => f.id === targetId); const toIndex = catFeeds.findIndex((f) => f.id === targetId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
const [moved] = catFeeds.splice(fromIndex, 1); const [moved] = catFeeds.splice(fromIndex, 1);
catFeeds.splice(toIndex, 0, moved); catFeeds.splice(toIndex, 0, moved);
await newsStore.reorderFeeds(catFeeds.map(f => f.id)); await newsStore.reorderFeeds(catFeeds.map((f) => f.id));
} }
} }
} }
draggedCategoryId = null; draggedCategoryId = null;
draggedFeedId = null; draggedFeedId = null;
} }
@@ -111,7 +131,7 @@
async function saveCategory() { async function saveCategory() {
if (!editingCategoryId) return; if (!editingCategoryId) return;
const cat = newsStore.categories.find(c => c.id === editingCategoryId); const cat = newsStore.categories.find((c) => c.id === editingCategoryId);
if (cat) { if (cat) {
await newsStore.updateCategory({ ...cat, name: editingCategoryName }); await newsStore.updateCategory({ ...cat, name: editingCategoryName });
} }
@@ -126,13 +146,16 @@
async function saveFeed() { async function saveFeed() {
if (!editingFeedId) return; if (!editingFeedId) return;
const feed = newsStore.feeds.find(f => f.id === editingFeedId); const feed = newsStore.feeds.find((f) => f.id === editingFeedId);
if (feed) { if (feed) {
await newsStore.updateFeed({ await newsStore.updateFeed(
...feed, {
title: editingFeedTitle, ...feed,
id: editingFeedUrl title: editingFeedTitle,
}, editingFeedId); id: editingFeedUrl,
},
editingFeedId
);
} }
editingFeedId = null; editingFeedId = null;
} }
@@ -145,14 +168,14 @@
try { try {
const text = await file.text(); const text = await file.text();
const { feeds, categories } = parseOPML(text); const { feeds, categories } = parseOPML(text);
if (categories.length > 0) { if (categories.length > 0) {
await db.saveCategories(categories as any); await db.saveCategories(categories as any);
} }
if (feeds.length > 0) { if (feeds.length > 0) {
await db.saveFeeds(feeds as any); await db.saveFeeds(feeds as any);
} }
toast.success(`Imported ${feeds.length} feeds`); toast.success(`Imported ${feeds.length} feeds`);
await newsStore.init(); await newsStore.init();
} catch (err) { } catch (err) {
@@ -183,234 +206,344 @@
} }
</script> </script>
<aside <aside
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full" class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar
? 'translate-x-0'
: '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
> >
<div class="flex flex-col gap-6 py-6 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container"> <div
<nav class="flex flex-col gap-1 px-2"> class="flex flex-col gap-2 pt-6 pb-2 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container"
<button >
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'all' && newsStore.selectedFeedId === null ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}" <nav class="flex flex-col gap-1 px-2">
onclick={() => { newsStore.selectView('all'); newsStore.readingArticle = null; newsStore.showSidebar = false; }} <button
> class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
<Home size={20} /> 'all' && newsStore.selectedFeedId === null
<span>Top stories</span> ? 'bg-accent-blue/10 text-accent-blue font-semibold'
</button> : 'text-text-primary hover:bg-bg-secondary'}"
<button onclick={() => {
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'following' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}" newsStore.selectView('all');
onclick={() => { newsStore.selectView('following'); newsStore.readingArticle = null; newsStore.showSidebar = false; }} newsStore.readingArticle = null;
> newsStore.showSidebar = false;
<Star size={20} /> }}
<span>Following</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'saved' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('saved'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Bookmark size={20} />
<span>Saved stories</span>
</button>
</nav>
<div class="px-6 py-2">
<div class="h-px bg-border-color w-full"></div>
</div>
<div class="flex-1 px-2 overflow-y-auto">
<div class="flex items-center justify-between px-4 mb-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Subscriptions</h3>
<button
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode ? 'text-accent-blue' : ''}"
onclick={() => isManageMode = !isManageMode}
title="Manage feeds"
> >
{#if isManageMode} <Home size={20} />
<X size={14} /> <span>Top stories</span>
{:else}
<Edit2 size={14} />
{/if}
</button> </button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'following'
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('following');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
<Star size={20} />
<span>Following</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'saved'
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('saved');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
<Bookmark size={20} />
<span>Saved stories</span>
</button>
</nav>
<div class="px-6 py-2">
<div class="h-px bg-border-color w-full"></div>
</div> </div>
{#if isManageMode} <div class="flex-1 px-2 overflow-y-auto">
<div class="px-4 mb-4 space-y-3"> <div class="flex items-center justify-between px-4 mb-4">
<div class="flex gap-1"> <h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">
<input Subscriptions
type="text" </h3>
bind:value={newCategoryName} <button
placeholder="Add category..." class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30" ? 'text-accent-blue'
onkeydown={(e) => e.key === 'Enter' && addCategory()} : ''}"
/> onclick={() => (isManageMode = !isManageMode)}
<button class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors" onclick={addCategory}> title="Manage feeds"
<Plus size={14} /> >
</button> {#if isManageMode}
</div> <X size={14} />
<div class="flex gap-2"> {:else}
<label class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer"> <Edit2 size={14} />
<Upload size={12} /> {/if}
Import </button>
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
<button
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
onclick={handleExport}
>
<Download size={12} />
Export
</button>
</div>
</div> </div>
{/if}
<div class="space-y-1" role="list"> {#if isManageMode}
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)} <div class="px-4 mb-4 space-y-3">
{@const catFeeds = getFeedsForCategory(cat.id)} <div class="flex gap-1">
{#if catFeeds.length > 0 || isManageMode} <input
<div type="text"
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}" bind:value={newCategoryName}
draggable={isManageMode} placeholder="Add category..."
role="listitem" class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
ondragstart={(e) => handleDragStart(e, 'category', cat.id)} onkeydown={(e) => e.key === 'Enter' && addCategory()}
ondragover={(e) => handleDragOver(e, cat.id)} />
ondrop={(e) => handleDrop(e, 'category', cat.id)} <button
> class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
<div class="flex items-center group"> onclick={addCategory}
{#if isManageMode} >
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2"> <Plus size={14} />
<GripVertical size={14} /> </button>
</div> </div>
{/if} <div class="flex gap-2">
<label
{#if editingCategoryId === cat.id} class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer"
<div class="flex-1 flex items-center gap-1 px-2 py-1"> >
<input <Upload size={12} />
type="text" Import
bind:value={editingCategoryName} <input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none" </label>
onkeydown={(e) => e.key === 'Enter' && saveCategory()} <button
/> class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button> onclick={handleExport}
</div> >
{:else} <Download size={12} />
<button Export
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors text-left min-w-0" </button>
onclick={() => toggleCategory(cat.id)} </div>
title={cat.name} </div>
> {/if}
{#if expandedCategories[cat.id]}
<ChevronDown size={16} /> <div class="space-y-1" role="list">
{:else} {#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
<ChevronRight size={16} /> {@const catFeeds = getFeedsForCategory(cat.id)}
{/if} {#if catFeeds.length > 0 || isManageMode}
<span class="truncate">{cat.name}</span> <div
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full">{catFeeds.length}</span> class="space-y-1 rounded-xl transition-all {dragOverId === cat.id
</button> ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
: ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
ondragover={(e) => handleDragOver(e, cat.id)}
ondrop={(e) => handleDrop(e, 'category', cat.id)}
>
<div class="flex items-center group">
{#if isManageMode} {#if isManageMode}
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2"> <div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button> <GripVertical size={14} />
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteCategory(cat.id)}><Trash2 size={12} /></button>
</div> </div>
{/if} {/if}
{/if}
</div>
{#if expandedCategories[cat.id]} {#if editingCategoryId === cat.id}
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list"> <div class="flex-1 flex items-center gap-1 px-2 py-1">
{#each catFeeds as feed (feed.id)} <input
<div type="text"
class="flex items-center group rounded-xl transition-all {dragOverId === feed.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}" bind:value={editingCategoryName}
draggable={isManageMode} class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
role="listitem" onkeydown={(e) => e.key === 'Enter' && saveCategory()}
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)} />
ondragover={(e) => handleDragOver(e, feed.id)} <button class="text-green-500" onclick={saveCategory}><Save size={14} /></button
ondrop={(e) => handleDrop(e, 'feed', feed.id)} >
</div>
{:else}
<button
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors text-left min-w-0 {newsStore.selectedCategoryId ===
cat.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectCategory(cat.id);
toggleCategory(cat.id);
newsStore.readingArticle = null;
if (!isManageMode) newsStore.showSidebar = false;
}}
title={cat.name}
> >
{#if isManageMode} {#if expandedCategories[cat.id]}
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2"> <ChevronDown size={16} />
<GripVertical size={12} />
</div>
{/if}
{#if editingFeedId === feed.id}
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
<input
type="text"
bind:value={editingFeedTitle}
placeholder="Feed title"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
/>
<input
type="text"
bind:value={editingFeedUrl}
placeholder="Feed URL"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
/>
<div class="flex justify-end gap-1 mt-1">
<button class="p-1 text-red-500 hover:bg-red-500/10 rounded" onclick={() => editingFeedId = null}><X size={14} /></button>
<button class="p-1 text-green-500 hover:bg-green-500/10 rounded" onclick={saveFeed}><Save size={14} /></button>
</div>
</div>
{:else} {:else}
<button <ChevronRight size={16} />
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId === feed.id ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0" {/if}
onclick={() => { newsStore.selectFeed(feed.id); newsStore.readingArticle = null; newsStore.showSidebar = false; }} <span class="truncate">{cat.name}</span>
title={feed.title} <span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full"
> >{catFeeds.length}</span
{#if feed.error} >
<AlertCircle size={16} class="text-red-500 flex-shrink-0" /> </button>
{:else if feed.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{:else}
<Hash size={16} class="flex-shrink-0" />
{/if}
<span class="truncate {feed.error ? 'text-red-500' : ''}">{feed.title}</span>
</button>
{#if isManageMode}
<div
class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button
>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteCategory(cat.id)}
><Trash2 size={12} /></button
>
</div>
{/if}
{/if}
</div>
{#if expandedCategories[cat.id]}
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
{#each catFeeds as feed (feed.id)}
<div
class="flex items-center group rounded-xl transition-all {dragOverId ===
feed.id
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
: ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
ondragover={(e) => handleDragOver(e, feed.id)}
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
>
{#if isManageMode} {#if isManageMode}
<div class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"> <div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditFeed(feed)} title="Edit feed"><Edit2 size={12} /></button> <GripVertical size={12} />
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteFeed(feed.id)} title="Delete feed"><Trash2 size={12} /></button>
</div> </div>
{/if} {/if}
{/if}
</div> {#if editingFeedId === feed.id}
{/each} <div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
</div> <input
{/if} type="text"
</div> bind:value={editingFeedTitle}
placeholder="Feed title"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
/>
<input
type="text"
bind:value={editingFeedUrl}
placeholder="Feed URL"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
/>
<div class="flex justify-end gap-1 mt-1">
<button
class="p-1 text-red-500 hover:bg-red-500/10 rounded"
onclick={() => (editingFeedId = null)}><X size={14} /></button
>
<button
class="p-1 text-green-500 hover:bg-green-500/10 rounded"
onclick={saveFeed}><Save size={14} /></button
>
</div>
</div>
{:else}
<button
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId ===
feed.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
onclick={() => {
newsStore.selectFeed(feed.id);
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
title={feed.title}
>
{#if feed.error}
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
{:else if feed.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{:else}
<Hash size={16} class="flex-shrink-0" />
{/if}
<span class="truncate {feed.error ? 'text-red-500' : ''}"
>{feed.title}</span
>
</button>
{#if feed.error && !isManageMode}
<div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => newsStore.refreshFeed(feed.id)}
title="Retry fetching feed"
>
<RefreshCw size={12} />
</button>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteFeed(feed.id)}
title="Remove feed"
>
<Trash2 size={12} />
</button>
</div>
{/if}
{#if isManageMode}
<div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => startEditFeed(feed)}
title="Edit feed"><Edit2 size={12} /></button
>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteFeed(feed.id)}
title="Delete feed"><Trash2 size={12} /></button
>
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
{#if newsStore.feeds.length === 0}
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
{/if} {/if}
{/each} </div>
{#if newsStore.feeds.length === 0}
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
{/if}
</div> </div>
</div>
<div class="flex flex-col items-center pb-24 md:pb-6 space-y-4"> <div class="flex flex-col items-center pb-20 md:pb-4 space-y-2">
<div class="flex flex-col items-center space-y-1"> <button
<a class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
href="https://git.quad4.io/Quad4-Software/webnews" onclick={onOpenSettings}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
> >
<GitBranch size={13} /> <SettingsIcon size={18} />
<span>v0.1.0</span> <span class="font-medium text-sm">Settings</span>
</a> </button>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a href="https://quad4.io" target="_blank" rel="noopener noreferrer" class="hover:text-accent-blue transition-colors">Quad4</a>
</p>
</div>
<button <div class="flex flex-col items-center">
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]" <a
onclick={onOpenSettings} href="https://git.quad4.io/Quad4-Software/webnews"
> target="_blank"
<SettingsIcon size={18} /> rel="noopener noreferrer"
<span class="font-medium text-sm">Settings</span> class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
</button> >
</div> <GitBranch size={13} />
<span>v{APP_VERSION}</span>
</a>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a
href="https://quad4.io"
target="_blank"
rel="noopener noreferrer"
class="hover:text-accent-blue transition-colors">Quad4</a
>
</p>
</div>
</div>
</div> </div>
</aside> </aside>

View File

@@ -7,27 +7,31 @@
info: Info, info: Info,
success: CheckCircle, success: CheckCircle,
error: AlertCircle, error: AlertCircle,
warning: AlertTriangle warning: AlertTriangle,
}; };
const colors = { const colors = {
info: 'text-blue-500 bg-bg-secondary/95 border-blue-500/30', info: 'text-blue-500 bg-bg-secondary/95 border-blue-500/30',
success: 'text-green-500 bg-bg-secondary/95 border-green-500/30', success: 'text-green-500 bg-bg-secondary/95 border-green-500/30',
error: 'text-red-500 bg-bg-secondary/95 border-red-500/30', error: 'text-red-500 bg-bg-secondary/95 border-red-500/30',
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30' warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30',
}; };
</script> </script>
<div class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4"> <div
class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4"
>
{#each toast.toasts as t (t.id)} {#each toast.toasts as t (t.id)}
<div <div
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[t.type]}" class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[
t.type
]}"
in:fly={{ y: 20, duration: 300 }} in:fly={{ y: 20, duration: 300 }}
out:fade={{ duration: 200 }} out:fade={{ duration: 200 }}
> >
<svelte:component this={icons[t.type]} size={20} class="flex-shrink-0" /> <svelte:component this={icons[t.type]} size={20} class="flex-shrink-0" />
<p class="text-sm font-medium flex-1 leading-snug">{t.message}</p> <p class="text-sm font-medium flex-1 leading-snug">{t.message}</p>
<button <button
class="text-text-secondary hover:text-text-primary transition-colors p-1" class="text-text-secondary hover:text-text-primary transition-colors p-1"
onclick={() => toast.remove(t.id)} onclick={() => toast.remove(t.id)}
> >
@@ -36,4 +40,3 @@
</div> </div>
{/each} {/each}
</div> </div>

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/> <path d="M4 11a9 9 0 0 1 9 9"/>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M4 4a16 16 0 0 1 16 16"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <circle cx="5" cy="19" r="1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -1,5 +1,9 @@
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { SQLiteConnection, type SQLiteDBConnection, CapacitorSQLite } from '@capacitor-community/sqlite'; import {
SQLiteConnection,
type SQLiteDBConnection,
CapacitorSQLite,
} from '@capacitor-community/sqlite';
export interface Category { export interface Category {
id: string; id: string;
@@ -83,10 +87,15 @@ export interface IDB {
saveCategory(category: Category): Promise<void>; saveCategory(category: Category): Promise<void>;
saveCategories(categories: Category[]): Promise<void>; saveCategories(categories: Category[]): Promise<void>;
deleteCategory(id: string): Promise<void>; deleteCategory(id: string): Promise<void>;
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>; getArticles(
feedId?: string,
offset?: number,
limit?: number,
categoryId?: string
): Promise<Article[]>;
saveArticles(articles: Article[]): Promise<void>; saveArticles(articles: Article[]): Promise<void>;
searchArticles(query: string, limit?: number): Promise<Article[]>; searchArticles(query: string, limit?: number): Promise<Article[]>;
getReadingHistory(days?: number): Promise<{ date: number, count: number }[]>; getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
markAsRead(id: string): Promise<void>; markAsRead(id: string): Promise<void>;
bulkMarkRead(ids: string[]): Promise<void>; bulkMarkRead(ids: string[]): Promise<void>;
bulkDelete(ids: string[]): Promise<void>; bulkDelete(ids: string[]): Promise<void>;
@@ -122,14 +131,23 @@ class IndexedDBImpl implements IDB {
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result; const db = (event.target as IDBOpenDBRequest).result;
const transaction = (event.target as IDBOpenDBRequest).transaction!; const transaction = (event.target as IDBOpenDBRequest).transaction!;
if (!db.objectStoreNames.contains('feeds')) db.createObjectStore('feeds', { keyPath: 'id' }); if (!db.objectStoreNames.contains('feeds'))
if (!db.objectStoreNames.contains('categories')) db.createObjectStore('categories', { keyPath: 'id' }); db.createObjectStore('feeds', { keyPath: 'id' });
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings', { keyPath: 'id' }); if (!db.objectStoreNames.contains('categories'))
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles') ? db.createObjectStore('articles', { keyPath: 'id' }) : transaction.objectStore('articles'); db.createObjectStore('categories', { keyPath: 'id' });
if (!articleStore.indexNames.contains('feedId')) articleStore.createIndex('feedId', 'feedId', { unique: false }); if (!db.objectStoreNames.contains('settings'))
if (!articleStore.indexNames.contains('pubDate')) articleStore.createIndex('pubDate', 'pubDate', { unique: false }); db.createObjectStore('settings', { keyPath: 'id' });
if (!articleStore.indexNames.contains('saved')) articleStore.createIndex('saved', 'saved', { unique: false }); let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles')
if (!articleStore.indexNames.contains('readAt')) articleStore.createIndex('readAt', 'readAt', { unique: false }); ? db.createObjectStore('articles', { keyPath: 'id' })
: transaction.objectStore('articles');
if (!articleStore.indexNames.contains('feedId'))
articleStore.createIndex('feedId', 'feedId', { unique: false });
if (!articleStore.indexNames.contains('pubDate'))
articleStore.createIndex('pubDate', 'pubDate', { unique: false });
if (!articleStore.indexNames.contains('saved'))
articleStore.createIndex('saved', 'saved', { unique: false });
if (!articleStore.indexNames.contains('readAt'))
articleStore.createIndex('readAt', 'readAt', { unique: false });
}; };
}); });
} }
@@ -159,7 +177,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('feeds', 'readwrite'); const transaction = db.transaction('feeds', 'readwrite');
const store = transaction.objectStore('feeds'); const store = transaction.objectStore('feeds');
feeds.forEach(f => store.put(f)); feeds.forEach((f) => store.put(f));
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
}); });
@@ -174,7 +192,10 @@ class IndexedDBImpl implements IDB {
const request = articleStore.index('feedId').openKeyCursor(IDBKeyRange.only(id)); const request = articleStore.index('feedId').openKeyCursor(IDBKeyRange.only(id));
request.onsuccess = (event) => { request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursor>).result; const cursor = (event.target as IDBRequest<IDBCursor>).result;
if (cursor) { articleStore.delete(cursor.primaryKey); cursor.continue(); } if (cursor) {
articleStore.delete(cursor.primaryKey);
cursor.continue();
}
}; };
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
@@ -206,7 +227,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('categories', 'readwrite'); const transaction = db.transaction('categories', 'readwrite');
const store = transaction.objectStore('categories'); const store = transaction.objectStore('categories');
categories.forEach(c => store.put(c)); categories.forEach((c) => store.put(c));
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
}); });
@@ -221,21 +242,54 @@ class IndexedDBImpl implements IDB {
const request = feedStore.getAll(); const request = feedStore.getAll();
request.onsuccess = () => { request.onsuccess = () => {
const feeds = request.result as Feed[]; const feeds = request.result as Feed[];
feeds.forEach(f => { if (f.categoryId === id) { f.categoryId = 'uncategorized'; feedStore.put(f); } }); feeds.forEach((f) => {
if (f.categoryId === id) {
f.categoryId = 'uncategorized';
feedStore.put(f);
}
});
}; };
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
}); });
} }
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open(); const db = await this.open();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly'); const transaction = db.transaction(['articles', 'feeds'], 'readonly');
const store = transaction.objectStore('articles'); const store = transaction.objectStore('articles');
let request: IDBRequest<any[]>; let request: IDBRequest<any[]>;
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
else request = store.index('pubDate').getAll(); if (feedId) {
request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
} else if (categoryId) {
// For IndexedDB, we need to get all feeds in category first
const feedStore = transaction.objectStore('feeds');
const feedsRequest = feedStore.getAll();
feedsRequest.onsuccess = () => {
const feeds = feedsRequest.result as Feed[];
const catFeedIds = new Set(
feeds.filter((f) => f.categoryId === categoryId).map((f) => f.id)
);
const allArticlesRequest = store.getAll();
allArticlesRequest.onsuccess = () => {
const articles = allArticlesRequest.result as Article[];
const filtered = articles.filter((a) => catFeedIds.has(a.feedId));
filtered.sort((a, b) => b.pubDate - a.pubDate);
resolve(filtered.slice(offset, offset + limit));
};
};
return;
} else {
request = store.index('pubDate').getAll();
}
request.onsuccess = () => { request.onsuccess = () => {
const articles = request.result as Article[]; const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate); articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -250,7 +304,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite'); const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles'); const store = transaction.objectStore('articles');
articles.forEach(article => store.put(article)); articles.forEach((article) => store.put(article));
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
}); });
@@ -268,7 +322,11 @@ class IndexedDBImpl implements IDB {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result; const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor && results.length < limit) { if (cursor && results.length < limit) {
const article = cursor.value as Article; const article = cursor.value as Article;
if (article.title.toLowerCase().includes(lowQuery) || article.description.toLowerCase().includes(lowQuery) || (article.content && article.content.toLowerCase().includes(lowQuery))) { if (
article.title.toLowerCase().includes(lowQuery) ||
article.description.toLowerCase().includes(lowQuery) ||
(article.content && article.content.toLowerCase().includes(lowQuery))
) {
results.push(article); results.push(article);
} }
cursor.continue(); cursor.continue();
@@ -278,12 +336,12 @@ class IndexedDBImpl implements IDB {
}); });
} }
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> { async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
const db = await this.open(); const db = await this.open();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly'); const transaction = db.transaction('articles', 'readonly');
const index = transaction.objectStore('articles').index('readAt'); const index = transaction.objectStore('articles').index('readAt');
const startTime = Date.now() - (days * 24 * 60 * 60 * 1000); const startTime = Date.now() - days * 24 * 60 * 60 * 1000;
const request = index.openCursor(IDBKeyRange.lowerBound(startTime)); const request = index.openCursor(IDBKeyRange.lowerBound(startTime));
const history: Record<string, number> = {}; const history: Record<string, number> = {};
request.onsuccess = (event) => { request.onsuccess = (event) => {
@@ -291,12 +349,18 @@ class IndexedDBImpl implements IDB {
if (cursor) { if (cursor) {
const article = cursor.value as Article; const article = cursor.value as Article;
if (article.readAt) { if (article.readAt) {
const date = new Date(article.readAt).toISOString().split('T')[0]; const d = new Date(article.readAt);
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
history[date] = (history[date] || 0) + 1; history[date] = (history[date] || 0) + 1;
} }
cursor.continue(); cursor.continue();
} else { } else {
resolve(Object.entries(history).map(([date, count]) => ({ date: new Date(date).getTime(), count }))); resolve(
Object.entries(history).map(([date, count]) => ({
date: new Date(date).getTime(),
count,
}))
);
} }
}; };
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
@@ -327,7 +391,11 @@ class IndexedDBImpl implements IDB {
const request = store.get(id); const request = store.get(id);
request.onsuccess = () => { request.onsuccess = () => {
const article = request.result as Article; const article = request.result as Article;
if (article && !article.read) { article.read = true; article.readAt = now; store.put(article); } if (article && !article.read) {
article.read = true;
article.readAt = now;
store.put(article);
}
}; };
} }
} }
@@ -347,7 +415,10 @@ class IndexedDBImpl implements IDB {
const request = store.get(id); const request = store.get(id);
request.onsuccess = () => { request.onsuccess = () => {
const article = request.result as Article; const article = request.result as Article;
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); } if (article) {
article.saved = !article.saved;
store.put({ ...article, saved: article.saved ? 1 : 0 });
}
}; };
} }
} }
@@ -357,14 +428,18 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite'); const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles'); const store = transaction.objectStore('articles');
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const request = store.openCursor(); const request = store.openCursor();
let count = 0; let count = 0;
request.onsuccess = (event) => { request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result; const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) { if (cursor) {
const article = cursor.value as Article; const article = cursor.value as Article;
if (article.content && !article.saved && article.pubDate < cutoff) { delete article.content; cursor.update(article); count++; } if (article.content && !article.saved && article.pubDate < cutoff) {
delete article.content;
cursor.update(article);
count++;
}
cursor.continue(); cursor.continue();
} else resolve(count); } else resolve(count);
}; };
@@ -379,7 +454,10 @@ class IndexedDBImpl implements IDB {
const request = store.get(id); const request = store.get(id);
request.onsuccess = () => { request.onsuccess = () => {
const article = request.result as Article; const article = request.result as Article;
if (article) { article.content = content; store.put(article); } if (article) {
article.content = content;
store.put(article);
}
}; };
} }
@@ -391,8 +469,11 @@ class IndexedDBImpl implements IDB {
const request = store.get(id); const request = store.get(id);
request.onsuccess = () => { request.onsuccess = () => {
const article = request.result as Article; const article = request.result as Article;
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); resolve(article.saved); } if (article) {
else resolve(false); article.saved = !article.saved;
store.put({ ...article, saved: article.saved ? 1 : 0 });
resolve(article.saved);
} else resolve(false);
}; };
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
@@ -402,7 +483,10 @@ class IndexedDBImpl implements IDB {
const db = await this.open(); const db = await this.open();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly'); const transaction = db.transaction('articles', 'readonly');
const request = transaction.objectStore('articles').index('saved').getAll(IDBKeyRange.only(1)); const request = transaction
.objectStore('articles')
.index('saved')
.getAll(IDBKeyRange.only(1));
request.onsuccess = () => { request.onsuccess = () => {
const articles = request.result as Article[]; const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate); articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -419,9 +503,21 @@ class IndexedDBImpl implements IDB {
const request = transaction.objectStore('settings').get('main'); const request = transaction.objectStore('settings').get('main');
request.onsuccess = () => { request.onsuccess = () => {
const defaults: Settings = { const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [], theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' }, shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 } relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
}; };
resolve({ ...defaults, ...(request.result || {}) }); resolve({ ...defaults, ...(request.result || {}) });
}; };
@@ -441,8 +537,14 @@ class IndexedDBImpl implements IDB {
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
const db = await this.open(); const db = await this.open();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction(['feeds', 'categories', 'articles', 'settings'], 'readwrite'); const transaction = db.transaction(
transaction.objectStore('feeds').clear(); transaction.objectStore('categories').clear(); transaction.objectStore('articles').clear(); transaction.objectStore('settings').clear(); ['feeds', 'categories', 'articles', 'settings'],
'readwrite'
);
transaction.objectStore('feeds').clear();
transaction.objectStore('categories').clear();
transaction.objectStore('articles').clear();
transaction.objectStore('settings').clear();
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
}); });
@@ -456,16 +558,16 @@ class CapacitorSQLiteDBImpl implements IDB {
async open(): Promise<SQLiteDBConnection> { async open(): Promise<SQLiteDBConnection> {
if (this.db) return this.db; if (this.db) return this.db;
if (!this.sqlite) this.sqlite = new SQLiteConnection(CapacitorSQLite); if (!this.sqlite) this.sqlite = new SQLiteConnection(CapacitorSQLite);
const ret = await this.sqlite.checkConnectionsConsistency(); const ret = await this.sqlite.checkConnectionsConsistency();
const isConn = (await this.sqlite.isConnection(DB_NAME, false)).result; const isConn = (await this.sqlite.isConnection(DB_NAME, false)).result;
if (ret.result && isConn) { if (ret.result && isConn) {
this.db = await this.sqlite.retrieveConnection(DB_NAME, false); this.db = await this.sqlite.retrieveConnection(DB_NAME, false);
} else { } else {
this.db = await this.sqlite.createConnection(DB_NAME, false, "no-encryption", 1, false); this.db = await this.sqlite.createConnection(DB_NAME, false, 'no-encryption', 1, false);
} }
await this.db.open(); await this.db.open();
const queries = [ const queries = [
@@ -474,7 +576,7 @@ class CapacitorSQLiteDBImpl implements IDB {
`CREATE TABLE IF NOT EXISTS articles (id TEXT PRIMARY KEY, feedId TEXT, title TEXT, link TEXT, description TEXT, content TEXT, author TEXT, pubDate INTEGER, read INTEGER, saved INTEGER, imageUrl TEXT, readAt INTEGER);`, `CREATE TABLE IF NOT EXISTS articles (id TEXT PRIMARY KEY, feedId TEXT, title TEXT, link TEXT, description TEXT, content TEXT, author TEXT, pubDate INTEGER, read INTEGER, saved INTEGER, imageUrl TEXT, readAt INTEGER);`,
`CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`, `CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`,
`CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`, `CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`,
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);` `CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`,
]; ];
for (const q of queries) { for (const q of queries) {
@@ -487,16 +589,20 @@ class CapacitorSQLiteDBImpl implements IDB {
async getFeeds(): Promise<Feed[]> { async getFeeds(): Promise<Feed[]> {
const db = await this.open(); const db = await this.open();
const res = await db.query('SELECT * FROM feeds ORDER BY "order" ASC'); const res = await db.query('SELECT * FROM feeds ORDER BY "order" ASC');
return (res.values || []).map(f => ({ ...f, enabled: f.enabled === 1 })); return (res.values || []).map((f) => ({ ...f, enabled: f.enabled === 1 }));
} }
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); } async saveFeed(feed: Feed): Promise<void> {
await this.saveFeeds([feed]);
}
async saveFeeds(feeds: Feed[]): Promise<void> { async saveFeeds(feeds: Feed[]): Promise<void> {
const db = await this.open(); const db = await this.open();
for (const f of feeds) { for (const f of feeds) {
await db.run('INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)', await db.run(
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]); 'INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]
);
} }
} }
@@ -512,12 +618,18 @@ class CapacitorSQLiteDBImpl implements IDB {
return res.values || []; return res.values || [];
} }
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); } async saveCategory(category: Category): Promise<void> {
await this.saveCategories([category]);
}
async saveCategories(categories: Category[]): Promise<void> { async saveCategories(categories: Category[]): Promise<void> {
const db = await this.open(); const db = await this.open();
for (const c of categories) { for (const c of categories) {
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [c.id, c.name, c.order]); await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [
c.id,
c.name,
c.order,
]);
} }
} }
@@ -527,52 +639,99 @@ class CapacitorSQLiteDBImpl implements IDB {
await db.run('DELETE FROM categories WHERE id = ?', [id]); await db.run('DELETE FROM categories WHERE id = ?', [id]);
} }
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open(); const db = await this.open();
let res; let res;
if (feedId) { if (feedId) {
res = await db.query('SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?', [feedId, limit, offset]); res = await db.query(
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
[feedId, limit, offset]
);
} else if (categoryId) {
res = await db.query(
`SELECT a.* FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`,
[categoryId, limit, offset]
);
} else { } else {
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [limit, offset]); res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
limit,
offset,
]);
} }
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 })); return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
} }
async saveArticles(articles: Article[]): Promise<void> { async saveArticles(articles: Article[]): Promise<void> {
const db = await this.open(); const db = await this.open();
for (const a of articles) { for (const a of articles) {
await db.run(`INSERT OR REPLACE INTO articles await db.run(
`INSERT OR REPLACE INTO articles
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt) (id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read ? 1 : 0, a.saved ? 1 : 0, a.imageUrl, a.readAt]); [
a.id,
a.feedId,
a.title,
a.link,
a.description,
a.content,
a.author,
a.pubDate,
a.read ? 1 : 0,
a.saved ? 1 : 0,
a.imageUrl,
a.readAt,
]
);
} }
} }
async searchArticles(query: string, limit = 50): Promise<Article[]> { async searchArticles(query: string, limit = 50): Promise<Article[]> {
const db = await this.open(); const db = await this.open();
const q = `%${query}%`; const q = `%${query}%`;
const res = await db.query(`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`, [q, q, q, limit]); const res = await db.query(
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 })); `SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`,
[q, q, q, limit]
);
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
} }
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> { async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
const db = await this.open(); const db = await this.open();
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.query(` const res = await db.query(
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count `
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles FROM articles
WHERE read = 1 AND readAt > ? WHERE read = 1 AND readAt > ?
GROUP BY date GROUP BY date
ORDER BY date DESC`, [cutoff]); ORDER BY date DESC`,
return (res.values || []).map(row => ({ [cutoff]
date: new Date(row.date).getTime(), );
count: row.count return (res.values || []).map((row) => {
})); const [y, m, d] = row.date.split('-').map(Number);
const date = new Date(y, m - 1, d).getTime();
return {
date,
count: row.count,
};
});
} }
async markAsRead(id: string): Promise<void> { async markAsRead(id: string): Promise<void> {
const db = await this.open(); const db = await this.open();
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [Date.now(), id]); await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [
Date.now(),
id,
]);
} }
async bulkMarkRead(ids: string[]): Promise<void> { async bulkMarkRead(ids: string[]): Promise<void> {
@@ -591,14 +750,19 @@ class CapacitorSQLiteDBImpl implements IDB {
async bulkToggleSave(ids: string[]): Promise<void> { async bulkToggleSave(ids: string[]): Promise<void> {
const db = await this.open(); const db = await this.open();
for (const id of ids) { for (const id of ids) {
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]); await db.run(
'UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?',
[id]
);
} }
} }
async purgeOldContent(days: number): Promise<number> { async purgeOldContent(days: number): Promise<number> {
const db = await this.open(); const db = await this.open();
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [cutoff]); const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [
cutoff,
]);
return res.changes?.changes || 0; return res.changes?.changes || 0;
} }
@@ -609,7 +773,9 @@ class CapacitorSQLiteDBImpl implements IDB {
async toggleSave(id: string): Promise<boolean> { async toggleSave(id: string): Promise<boolean> {
const db = await this.open(); const db = await this.open();
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]); await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [
id,
]);
const res = await db.query('SELECT saved FROM articles WHERE id = ?', [id]); const res = await db.query('SELECT saved FROM articles WHERE id = ?', [id]);
return res.values?.[0]?.saved === 1; return res.values?.[0]?.saved === 1;
} }
@@ -617,16 +783,28 @@ class CapacitorSQLiteDBImpl implements IDB {
async getSavedArticles(): Promise<Article[]> { async getSavedArticles(): Promise<Article[]> {
const db = await this.open(); const db = await this.open();
const res = await db.query('SELECT * FROM articles WHERE saved = 1 ORDER BY pubDate DESC'); const res = await db.query('SELECT * FROM articles WHERE saved = 1 ORDER BY pubDate DESC');
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 })); return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
} }
async getSettings(): Promise<Settings> { async getSettings(): Promise<Settings> {
const db = await this.open(); const db = await this.open();
const res = await db.query("SELECT value FROM settings WHERE key = 'main'"); const res = await db.query("SELECT value FROM settings WHERE key = 'main'");
const defaults: Settings = { const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [], theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' }, shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 } relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
}; };
if (res.values && res.values.length > 0) { if (res.values && res.values.length > 0) {
return { ...defaults, ...JSON.parse(res.values[0].value) }; return { ...defaults, ...JSON.parse(res.values[0].value) };
@@ -636,7 +814,9 @@ class CapacitorSQLiteDBImpl implements IDB {
async saveSettings(settings: Settings): Promise<void> { async saveSettings(settings: Settings): Promise<void> {
const db = await this.open(); const db = await this.open();
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [JSON.stringify(settings)]); await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [
JSON.stringify(settings),
]);
} }
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
@@ -656,7 +836,7 @@ class CapacitorSQLiteDBImpl implements IDB {
path: 'Native SQLite', path: 'Native SQLite',
articles: artRes.values?.[0]?.count || 0, articles: artRes.values?.[0]?.count || 0,
feeds: feedRes.values?.[0]?.count || 0, feeds: feedRes.values?.[0]?.count || 0,
walEnabled: true walEnabled: true,
}; };
} }
} }
@@ -665,78 +845,150 @@ class WailsDBImpl implements IDB {
private async call<T>(method: string, ...args: any[]): Promise<T> { private async call<T>(method: string, ...args: any[]): Promise<T> {
const app = (window as any).go?.main?.App; const app = (window as any).go?.main?.App;
if (!app || !app[method]) throw new Error(`Wails method ${method} not found`); if (!app || !app[method]) throw new Error(`Wails method ${method} not found`);
// Add a 5 second timeout to all Wails calls to prevent infinite hangs // Add a 5 second timeout to all Wails calls to prevent infinite hangs
return Promise.race([ return Promise.race([
app[method](...args), app[method](...args),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Wails call ${method} timed out after 5s`)), 5000) setTimeout(() => reject(new Error(`Wails call ${method} timed out after 5s`)), 5000)
) ),
]) as Promise<T>; ]) as Promise<T>;
} }
async getFeeds(): Promise<Feed[]> { return JSON.parse(await this.call('GetFeeds')); } async getFeeds(): Promise<Feed[]> {
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); } return JSON.parse(await this.call('GetFeeds'));
async saveFeeds(feeds: Feed[]): Promise<void> { await this.call('SaveFeeds', JSON.stringify(feeds)); } }
async deleteFeed(id: string): Promise<void> { await this.call('DeleteFeed', id); } async saveFeed(feed: Feed): Promise<void> {
async getCategories(): Promise<Category[]> { return JSON.parse(await this.call('GetCategories')); } await this.saveFeeds([feed]);
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); } }
async saveCategories(categories: Category[]): Promise<void> { await this.call('SaveCategories', JSON.stringify(categories)); } async saveFeeds(feeds: Feed[]): Promise<void> {
await this.call('SaveFeeds', JSON.stringify(feeds));
}
async deleteFeed(id: string): Promise<void> {
await this.call('DeleteFeed', id);
}
async getCategories(): Promise<Category[]> {
return JSON.parse(await this.call('GetCategories'));
}
async saveCategory(category: Category): Promise<void> {
await this.saveCategories([category]);
}
async saveCategories(categories: Category[]): Promise<void> {
await this.call('SaveCategories', JSON.stringify(categories));
}
async deleteCategory(id: string): Promise<void> { async deleteCategory(id: string): Promise<void> {
const feeds = await this.getFeeds(); const feeds = await this.getFeeds();
for (const f of feeds) { if (f.categoryId === id) { f.categoryId = 'uncategorized'; await this.saveFeed(f); } } for (const f of feeds) {
if (f.categoryId === id) {
f.categoryId = 'uncategorized';
await this.saveFeed(f);
}
}
// Wait for feed updates then delete cat // Wait for feed updates then delete cat
await this.call('SaveCategories', JSON.stringify((await this.getCategories()).filter(c => c.id !== id))); await this.call(
'SaveCategories',
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
);
} }
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit)); } async getArticles(
async saveArticles(articles: Article[]): Promise<void> { await this.call('SaveArticles', JSON.stringify(articles)); } feedId?: string,
async searchArticles(query: string, limit = 50): Promise<Article[]> { return JSON.parse(await this.call('SearchArticles', query, limit)); } offset = 0,
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> { limit = 20,
categoryId?: string
): Promise<Article[]> {
return JSON.parse(
await this.call('GetArticles', feedId || '', offset, limit, categoryId || '')
);
}
async saveArticles(articles: Article[]): Promise<void> {
await this.call('SaveArticles', JSON.stringify(articles));
}
async searchArticles(query: string, limit = 50): Promise<Article[]> {
return JSON.parse(await this.call('SearchArticles', query, limit));
}
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
return JSON.parse(await this.call('GetReadingHistory', days)); return JSON.parse(await this.call('GetReadingHistory', days));
} }
async markAsRead(id: string): Promise<void> { async markAsRead(id: string): Promise<void> {
await this.call('MarkAsRead', id); await this.call('MarkAsRead', id);
} }
async bulkMarkRead(ids: string[]): Promise<void> { for (const id of ids) await this.markAsRead(id); } async bulkMarkRead(ids: string[]): Promise<void> {
async bulkDelete(_ids: string[]): Promise<void> { /* Not directly in SQLite bridge yet, could add */ } for (const id of ids) await this.markAsRead(id);
async bulkToggleSave(ids: string[]): Promise<void> { for (const id of ids) await this.toggleSave(id); } }
async purgeOldContent(days: number): Promise<number> { return Number(await this.call('PurgeOldContent', days)); } async bulkDelete(_ids: string[]): Promise<void> {
/* Not directly in SQLite bridge yet, could add */
}
async bulkToggleSave(ids: string[]): Promise<void> {
for (const id of ids) await this.toggleSave(id);
}
async purgeOldContent(days: number): Promise<number> {
return Number(await this.call('PurgeOldContent', days));
}
async updateArticleContent(id: string, content: string): Promise<void> { async updateArticleContent(id: string, content: string): Promise<void> {
const articles = await this.getArticles('', 0, 1000); const articles = await this.getArticles('', 0, 1000);
const a = articles.find(art => art.id === id); const a = articles.find((art) => art.id === id);
if (a) { a.content = content; await this.call('UpdateArticle', JSON.stringify(a)); } if (a) {
a.content = content;
await this.call('UpdateArticle', JSON.stringify(a));
}
} }
async toggleSave(id: string): Promise<boolean> { async toggleSave(id: string): Promise<boolean> {
const articles = await this.getArticles('', 0, 1000); const articles = await this.getArticles('', 0, 1000);
const a = articles.find(art => art.id === id); const a = articles.find((art) => art.id === id);
if (a) { a.saved = !a.saved; await this.call('UpdateArticle', JSON.stringify(a)); return a.saved; } if (a) {
a.saved = !a.saved;
await this.call('UpdateArticle', JSON.stringify(a));
return a.saved;
}
return false; return false;
} }
async getSavedArticles(): Promise<Article[]> { async getSavedArticles(): Promise<Article[]> {
const articles = await this.getArticles('', 0, 5000); const articles = await this.getArticles('', 0, 5000);
return articles.filter(a => a.saved).sort((a, b) => b.pubDate - a.pubDate); return articles.filter((a) => a.saved).sort((a, b) => b.pubDate - a.pubDate);
} }
async getSettings(): Promise<Settings> { async getSettings(): Promise<Settings> {
const defaults: Settings = { const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [], theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' }, shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 } relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
}; };
const saved = await this.call<string>('GetSettings'); const saved = await this.call<string>('GetSettings');
if (!saved) return defaults; // Handle empty string case if (!saved) return defaults; // Handle empty string case
try { try {
return { ...defaults, ...JSON.parse(saved) }; return { ...defaults, ...JSON.parse(saved) };
} catch (e) { } catch (e) {
console.error("Failed to parse settings", e); console.error('Failed to parse settings', e);
return defaults; return defaults;
} }
} }
async saveSettings(settings: Settings): Promise<void> { await this.call('SaveSettings', JSON.stringify(settings)); } async saveSettings(settings: Settings): Promise<void> {
async clearAll(): Promise<void> { await this.call('ClearAll'); } await this.call('SaveSettings', JSON.stringify(settings));
}
async getStats(): Promise<DBStats> { return await this.call('GetDBStats'); } async clearAll(): Promise<void> {
async vacuum(): Promise<void> { await this.call('VacuumDB'); } await this.call('ClearAll');
async integrityCheck(): Promise<string> { return await this.call('CheckDBIntegrity'); } }
async getStats(): Promise<DBStats> {
return await this.call('GetDBStats');
}
async vacuum(): Promise<void> {
await this.call('VacuumDB');
}
async integrityCheck(): Promise<string> {
return await this.call('CheckDBIntegrity');
}
} }
// LazyDBWrapper allows deciding which implementation to use at runtime (lazily), // LazyDBWrapper allows deciding which implementation to use at runtime (lazily),
@@ -746,9 +998,10 @@ class LazyDBWrapper implements IDB {
private getImpl(): IDB { private getImpl(): IDB {
if (this.impl) return this.impl; if (this.impl) return this.impl;
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go); const isWails =
const isCapacitor = typeof window !== 'undefined' && (Capacitor.isNativePlatform()); typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
if (isWails) { if (isWails) {
this.impl = new WailsDBImpl(); this.impl = new WailsDBImpl();
@@ -758,40 +1011,96 @@ class LazyDBWrapper implements IDB {
this.impl = new IndexedDBImpl(); this.impl = new IndexedDBImpl();
} }
console.log(`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`); console.log(
`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`
);
return this.impl; return this.impl;
} }
getFeeds() { return this.getImpl().getFeeds(); } getFeeds() {
saveFeed(feed: Feed) { return this.getImpl().saveFeed(feed); } return this.getImpl().getFeeds();
saveFeeds(feeds: Feed[]) { return this.getImpl().saveFeeds(feeds); } }
deleteFeed(id: string) { return this.getImpl().deleteFeed(id); } saveFeed(feed: Feed) {
getCategories() { return this.getImpl().getCategories(); } return this.getImpl().saveFeed(feed);
saveCategory(category: Category) { return this.getImpl().saveCategory(category); } }
saveCategories(categories: Category[]) { return this.getImpl().saveCategories(categories); } saveFeeds(feeds: Feed[]) {
deleteCategory(id: string) { return this.getImpl().deleteCategory(id); } return this.getImpl().saveFeeds(feeds);
getArticles(feedId?: string, offset?: number, limit?: number) { return this.getImpl().getArticles(feedId, offset, limit); } }
saveArticles(articles: Article[]) { return this.getImpl().saveArticles(articles); } deleteFeed(id: string) {
searchArticles(query: string, limit?: number) { return this.getImpl().searchArticles(query, limit); } return this.getImpl().deleteFeed(id);
getReadingHistory(days?: number) { return this.getImpl().getReadingHistory(days); } }
markAsRead(id: string) { return this.getImpl().markAsRead(id); } getCategories() {
bulkMarkRead(ids: string[]) { return this.getImpl().bulkMarkRead(ids); } return this.getImpl().getCategories();
bulkDelete(ids: string[]) { return this.getImpl().bulkDelete(ids); } }
bulkToggleSave(ids: string[]) { return this.getImpl().bulkToggleSave(ids); } saveCategory(category: Category) {
purgeOldContent(days: number) { return this.getImpl().purgeOldContent(days); } return this.getImpl().saveCategory(category);
updateArticleContent(id: string, content: string) { return this.getImpl().updateArticleContent(id, content); } }
toggleSave(id: string) { return this.getImpl().toggleSave(id); } saveCategories(categories: Category[]) {
getSavedArticles() { return this.getImpl().getSavedArticles(); } return this.getImpl().saveCategories(categories);
getSettings() { return this.getImpl().getSettings(); } }
saveSettings(settings: Settings) { return this.getImpl().saveSettings(settings); } deleteCategory(id: string) {
clearAll() { return this.getImpl().clearAll(); } return this.getImpl().deleteCategory(id);
}
async getStats() { getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
const impl = this.getImpl(); return this.getImpl().getArticles(feedId, offset, limit, categoryId);
return impl.getStats ? impl.getStats() : { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false }; }
saveArticles(articles: Article[]) {
return this.getImpl().saveArticles(articles);
}
searchArticles(query: string, limit?: number) {
return this.getImpl().searchArticles(query, limit);
}
getReadingHistory(days?: number) {
return this.getImpl().getReadingHistory(days);
}
markAsRead(id: string) {
return this.getImpl().markAsRead(id);
}
bulkMarkRead(ids: string[]) {
return this.getImpl().bulkMarkRead(ids);
}
bulkDelete(ids: string[]) {
return this.getImpl().bulkDelete(ids);
}
bulkToggleSave(ids: string[]) {
return this.getImpl().bulkToggleSave(ids);
}
purgeOldContent(days: number) {
return this.getImpl().purgeOldContent(days);
}
updateArticleContent(id: string, content: string) {
return this.getImpl().updateArticleContent(id, content);
}
toggleSave(id: string) {
return this.getImpl().toggleSave(id);
}
getSavedArticles() {
return this.getImpl().getSavedArticles();
}
getSettings() {
return this.getImpl().getSettings();
}
saveSettings(settings: Settings) {
return this.getImpl().saveSettings(settings);
}
clearAll() {
return this.getImpl().clearAll();
}
async getStats() {
const impl = this.getImpl();
return impl.getStats
? impl.getStats()
: { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
}
async vacuum() {
const impl = this.getImpl();
if (impl.vacuum) await impl.vacuum();
}
async integrityCheck() {
const impl = this.getImpl();
return impl.integrityCheck ? await impl.integrityCheck() : 'N/A';
} }
async vacuum() { const impl = this.getImpl(); if (impl.vacuum) await impl.vacuum(); }
async integrityCheck() { const impl = this.getImpl(); return impl.integrityCheck ? await impl.integrityCheck() : 'N/A'; }
} }
export const db: IDB = new LazyDBWrapper(); export const db: IDB = new LazyDBWrapper();

View File

@@ -12,32 +12,38 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
// Group feeds by category // Group feeds by category
const categorizedFeeds: Record<string, Feed[]> = {}; const categorizedFeeds: Record<string, Feed[]> = {};
feeds.forEach(f => { feeds.forEach((f) => {
if (!categorizedFeeds[f.categoryId]) categorizedFeeds[f.categoryId] = []; if (!categorizedFeeds[f.categoryId]) categorizedFeeds[f.categoryId] = [];
categorizedFeeds[f.categoryId].push(f); categorizedFeeds[f.categoryId].push(f);
}); });
// Add categories and their feeds // Add categories and their feeds
[...categories].sort((a, b) => a.order - b.order).forEach(cat => { [...categories]
const catFeeds = categorizedFeeds[cat.id] || []; .sort((a, b) => a.order - b.order)
if (catFeeds.length === 0) return; .forEach((cat) => {
const catFeeds = categorizedFeeds[cat.id] || [];
if (catFeeds.length === 0) return;
xml += `
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
[...catFeeds].sort((a, b) => a.order - b.order).forEach(f => {
xml += ` xml += `
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
[...catFeeds]
.sort((a, b) => a.order - b.order)
.forEach((f) => {
xml += `
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`; <outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
}); });
xml += ` xml += `
</outline>`; </outline>`;
}); });
// Add uncategorized feeds // Add uncategorized feeds
const uncategorized = categorizedFeeds['uncategorized'] || []; const uncategorized = categorizedFeeds['uncategorized'] || [];
[...uncategorized].sort((a, b) => a.order - b.order).forEach(f => { [...uncategorized]
xml += ` .sort((a, b) => a.order - b.order)
.forEach((f) => {
xml += `
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`; <outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
}); });
xml += ` xml += `
</body> </body>
@@ -46,11 +52,14 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
return xml; return xml;
} }
export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Partial<Category>[] } { export function parseOPML(xml: string): {
feeds: Partial<Feed>[];
categories: Partial<Category>[];
} {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml'); const doc = parser.parseFromString(xml, 'text/xml');
const outlines = doc.querySelectorAll('body > outline'); const outlines = doc.querySelectorAll('body > outline');
const feeds: Partial<Feed>[] = []; const feeds: Partial<Feed>[] = [];
const categories: Partial<Category>[] = []; const categories: Partial<Category>[] = [];
@@ -67,7 +76,7 @@ export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Pa
categories.push({ categories.push({
id: categoryId, id: categoryId,
name: text, name: text,
order: categories.length order: categories.length,
}); });
const childFeeds = outline.querySelectorAll('outline[type="rss"]'); const childFeeds = outline.querySelectorAll('outline[type="rss"]');
@@ -90,17 +99,20 @@ function parseFeedOutline(el: Element, categoryId: string, order: number): Parti
enabled: true, enabled: true,
consecutiveErrors: 0, consecutiveErrors: 0,
fetchInterval: 30, fetchInterval: 30,
lastFetched: 0 lastFetched: 0,
}; };
} }
function escapeHTML(str: string): string { function escapeHTML(str: string): string {
return str.replace(/[&<>"']/g, m => ({ return str.replace(
'&': '&amp;', /[&<>"']/g,
'<': '&lt;', (m) =>
'>': '&gt;', ({
'"': '&quot;', '&': '&amp;',
"'": '&#39;' '<': '&lt;',
})[m] || m); '>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[m] || m
);
} }

View File

@@ -4,25 +4,34 @@ import { registerPlugin } from '@capacitor/core';
const RSS = registerPlugin<any>('RSS'); const RSS = registerPlugin<any>('RSS');
export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>, articles: Article[] }> { export async function fetchFeed(
feedUrl: string,
signal?: AbortSignal
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection // Try native RSS fetch first if on mobile to bypass CORS/Bot protection
if (newsStore.isCapacitor) { if (newsStore.isCapacitor) {
try { try {
// Capacitor plugin might not support signal directly, but we can check it
if (signal?.aborted) throw new Error('Aborted');
const data = await RSS.fetchFeed({ url: feedUrl }); const data = await RSS.fetchFeed({ url: feedUrl });
if (signal?.aborted) throw new Error('Aborted');
const articles: Article[] = data.articles.map((item: any) => ({ const articles: Article[] = data.articles.map((item: any) => ({
...item, ...item,
description: stripHtml(item.description || '').substring(0, 200) description: stripHtml(item.description || '').substring(0, 200),
})); }));
return { return {
feed: { feed: {
...data.feed, ...data.feed,
lastFetched: Date.now() lastFetched: Date.now(),
}, },
articles articles,
}; };
} catch (e: any) { } catch (e: any) {
if (e.message === 'Aborted') throw e;
console.warn('Native RSS fetch failed, falling back to API proxy:', e); console.warn('Native RSS fetch failed, falling back to API proxy:', e);
// Show actual error in toast if it's not a "not implemented" error // Show actual error in toast if it's not a "not implemented" error
if (e.message && !e.message.includes('not implemented')) { if (e.message && !e.message.includes('not implemented')) {
@@ -36,7 +45,10 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
if (newsStore.settings.authToken) { if (newsStore.settings.authToken) {
headers['X-Account-Number'] = newsStore.settings.authToken; headers['X-Account-Number'] = newsStore.settings.authToken;
} }
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, { headers }); const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, {
headers,
signal,
});
if (response.status === 401) { if (response.status === 401) {
newsStore.logout(); newsStore.logout();
throw new Error('Unauthorized'); throw new Error('Unauthorized');
@@ -45,7 +57,7 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
const errorText = await response.text(); const errorText = await response.text();
throw new Error(errorText || `Failed to fetch feed: ${response.status} ${response.statusText}`); throw new Error(errorText || `Failed to fetch feed: ${response.status} ${response.statusText}`);
} }
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
const text = await response.text(); const text = await response.text();
@@ -65,18 +77,18 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
} catch { } catch {
throw new Error('Failed to parse server response as JSON'); throw new Error('Failed to parse server response as JSON');
} }
const articles: Article[] = data.articles.map((item: any) => ({ const articles: Article[] = data.articles.map((item: any) => ({
...item, ...item,
description: stripHtml(item.description).substring(0, 200) description: stripHtml(item.description).substring(0, 200),
})); }));
return { return {
feed: { feed: {
...data.feed, ...data.feed,
lastFetched: Date.now() lastFetched: Date.now(),
}, },
articles articles,
}; };
} }
@@ -91,33 +103,62 @@ function stripHtml(html: string): string {
.trim(); .trim();
} }
export async function refreshAllFeeds() { export async function refreshAllFeeds(signal?: AbortSignal) {
const feeds = await db.getFeeds(); const feeds = await db.getFeeds();
for (const feed of feeds) { for (const feed of feeds) {
if (signal?.aborted) throw new Error('Aborted');
if (!feed.enabled) continue; if (!feed.enabled) continue;
const now = Date.now(); const now = Date.now();
const shouldFetch = now - feed.lastFetched > feed.fetchInterval * 60000 || feed.error; const shouldFetch = now - feed.lastFetched > feed.fetchInterval * 60000 || feed.error;
if (shouldFetch) { if (shouldFetch) {
try { try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id); const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({ await db.saveFeed({
...feed, ...feed,
...updatedFeed, ...updatedFeed,
error: undefined, error: undefined,
consecutiveErrors: 0 consecutiveErrors: 0,
}); });
await db.saveArticles(articles); await db.saveArticles(articles);
} catch (e: any) { } catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e); console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1; const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({ await db.saveFeed({
...feed, ...feed,
error: e.message || 'Unknown error', error: e.message || 'Unknown error',
consecutiveErrors consecutiveErrors,
}); });
} }
} }
} }
} }
export async function refreshFeed(feedId: string, signal?: AbortSignal) {
const feeds = await db.getFeeds();
const feed = feeds.find((f) => f.id === feedId);
if (!feed) return;
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0,
});
await db.saveArticles(articles);
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({
...feed,
error: e.message || 'Unknown error',
consecutiveErrors,
});
throw e;
}
}

View File

@@ -1,5 +1,5 @@
import { db, type Article, type Feed, type Settings, type Category } from './db'; import { db, type Article, type Feed, type Settings, type Category } from './db';
import { refreshAllFeeds } from './rss'; import { refreshAllFeeds, refreshFeed } from './rss';
import { toast } from './toast.svelte'; import { toast } from './toast.svelte';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
@@ -27,18 +27,19 @@ class NewsStore {
save: 's', save: 's',
read: 'r', read: 'r',
open: 'o', open: 'o',
toggleSelect: 'x' toggleSelect: 'x',
}, },
relevanceProfile: { relevanceProfile: {
categoryScores: {}, categoryScores: {},
feedScores: {}, feedScores: {},
totalInteractions: 0 totalInteractions: 0,
} },
}); });
loading = $state(false); loading = $state(false);
isInitialLoading = $state(false); isInitialLoading = $state(false);
showSidebar = $state(false); showSidebar = $state(false);
selectedFeedId = $state<string | null>(null); selectedFeedId = $state<string | null>(null);
selectedCategoryId = $state<string | null>(null);
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all'); currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
readingArticle = $state<any | null>(null); readingArticle = $state<any | null>(null);
searchQuery = $state(''); searchQuery = $state('');
@@ -46,13 +47,16 @@ class NewsStore {
isSelectMode = $state(false); isSelectMode = $state(false);
selectedArticleIds = $state(new Set<string>()); selectedArticleIds = $state(new Set<string>());
private limit = 20; private limit = 20;
private refreshController: AbortController | null = null;
// Connection status // Connection status
isOnline = $state(true); isOnline = $state(true);
ping = $state<number | null>(null); ping = $state<number | null>(null);
lastStatusCheck = $state<number>(Date.now()); lastStatusCheck = $state<number>(Date.now());
authInfo = $state<{required: boolean, mode: string, canReg: boolean} | null>(null); lastArticlesUpdate = $state<number>(Date.now());
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
isAuthenticated = $state(false); isAuthenticated = $state(false);
newlyRegisteredToken = $state<string | null>(null);
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go); isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform(); isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
@@ -69,20 +73,21 @@ class NewsStore {
this.loading = true; this.loading = true;
this.isInitialLoading = true; this.isInitialLoading = true;
const startTime = Date.now(); const startTime = Date.now();
log("Init started"); log('Init started');
try { try {
// Platform detection // Platform detection
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go); const isWails =
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform(); const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
let detectedWailsUrl: string | null = null; let detectedWailsUrl: string | null = null;
if (isWails) { if (isWails) {
log("Wails environment detected"); log('Wails environment detected');
// Wait a bit for bindings if they are not immediately available // Wait a bit for bindings if they are not immediately available
let retries = 0; let retries = 0;
while (retries < 15 && !(window as any).go?.main?.App?.GetAPIPort) { while (retries < 15 && !(window as any).go?.main?.App?.GetAPIPort) {
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
retries++; retries++;
} }
@@ -95,16 +100,16 @@ class NewsStore {
log(`Wails GetAPIPort failed: ${e}`); log(`Wails GetAPIPort failed: ${e}`);
} }
} else { } else {
log("Wails bindings not found after retries, continuing with defaults"); log('Wails bindings not found after retries, continuing with defaults');
} }
} else if (isCapacitor) { } else if (isCapacitor) {
log("Capacitor Native environment detected"); log('Capacitor Native environment detected');
} }
log("Fetching settings..."); log('Fetching settings...');
this.settings = await db.getSettings(); this.settings = await db.getSettings();
log("Settings loaded"); log('Settings loaded');
// Override API URL if running in Wails with detected port // Override API URL if running in Wails with detected port
// This prevents the DB setting (which might be stale or default) from breaking the connection // This prevents the DB setting (which might be stale or default) from breaking the connection
if (detectedWailsUrl) { if (detectedWailsUrl) {
@@ -112,11 +117,11 @@ class NewsStore {
} }
this.applyTheme(); this.applyTheme();
log("Checking status..."); log('Checking status...');
await this.checkStatus(); await this.checkStatus();
log(`Status checked. Authenticated: ${this.isAuthenticated}`); log(`Status checked. Authenticated: ${this.isAuthenticated}`);
if (this.authInfo?.required) { if (this.authInfo?.required) {
if (this.settings.authToken) { if (this.settings.authToken) {
const isValid = await this.verifyAuth(this.settings.authToken); const isValid = await this.verifyAuth(this.settings.authToken);
@@ -129,11 +134,11 @@ class NewsStore {
} }
if (this.isAuthenticated) { if (this.isAuthenticated) {
log("Loading feeds and categories..."); log('Loading feeds and categories...');
this.feeds = (await db.getFeeds()) || []; this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || []; this.categories = (await db.getCategories()) || [];
log(`Loaded ${this.feeds.length} feeds`); log(`Loaded ${this.feeds.length} feeds`);
// Ensure at least one category exists // Ensure at least one category exists
if (this.categories.length === 0) { if (this.categories.length === 0) {
const uncategorized = { id: 'uncategorized', name: 'Uncategorized', order: 0 }; const uncategorized = { id: 'uncategorized', name: 'Uncategorized', order: 0 };
@@ -141,9 +146,9 @@ class NewsStore {
this.categories = [uncategorized]; this.categories = [uncategorized];
} }
log("Loading articles..."); log('Loading articles...');
await this.loadArticles(); await this.loadArticles();
log("Articles loaded"); log('Articles loaded');
} }
} catch (e) { } catch (e) {
log(`Store initialization failed: ${e}`); log(`Store initialization failed: ${e}`);
@@ -151,18 +156,18 @@ class NewsStore {
// Forced minimum loading time to prevent flickering (1.2 seconds) // Forced minimum loading time to prevent flickering (1.2 seconds)
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
if (elapsed < 1200) { if (elapsed < 1200) {
await new Promise(resolve => setTimeout(resolve, 1200 - elapsed)); await new Promise((resolve) => setTimeout(resolve, 1200 - elapsed));
} }
this.loading = false; this.loading = false;
this.isInitialLoading = false; this.isInitialLoading = false;
this.isInitializing = false; this.isInitializing = false;
log("Init complete"); log('Init complete');
} }
if (this.settings.autoFetch && this.isAuthenticated) { if (this.settings.autoFetch && this.isAuthenticated) {
this.startAutoFetch(); this.startAutoFetch();
} }
this.startStatusChecking(); this.startStatusChecking();
} }
@@ -170,7 +175,7 @@ class NewsStore {
const apiBase = this.settings.apiBaseUrl || '/api'; const apiBase = this.settings.apiBaseUrl || '/api';
try { try {
const response = await fetch(`${apiBase}/auth/verify`, { const response = await fetch(`${apiBase}/auth/verify`, {
headers: { 'X-Account-Number': token } headers: { 'X-Account-Number': token },
}); });
return response.ok; return response.ok;
} catch { } catch {
@@ -184,7 +189,7 @@ class NewsStore {
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' }); const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
if (!response.ok) throw new Error('Registration failed'); if (!response.ok) throw new Error('Registration failed');
const data = await response.json(); const data = await response.json();
await this.login(data.accountNumber); this.newlyRegisteredToken = data.accountNumber;
return data.accountNumber; return data.accountNumber;
} catch { } catch {
toast.error('Could not generate account'); toast.error('Could not generate account');
@@ -217,7 +222,7 @@ class NewsStore {
async checkStatus() { async checkStatus() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const wasOnline = this.isOnline; const wasOnline = this.isOnline;
if (!navigator.onLine) { if (!navigator.onLine) {
this.isOnline = false; this.isOnline = false;
@@ -232,10 +237,10 @@ class NewsStore {
// Add a short timeout for the ping // Add a short timeout for the ping
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${apiBase}/ping`, { const response = await fetch(`${apiBase}/ping`, {
cache: 'no-store', cache: 'no-store',
signal: controller.signal signal: controller.signal,
}); });
clearTimeout(timeout); clearTimeout(timeout);
@@ -268,18 +273,27 @@ class NewsStore {
startStatusChecking() { startStatusChecking() {
this.checkStatus(); this.checkStatus();
if (this.statusInterval) clearInterval(this.statusInterval); if (this.statusInterval) clearInterval(this.statusInterval);
// Only run periodic status checks (ping) on web server // Only run periodic status checks (ping) on web server
// Mobile and Desktop should avoid unnecessary background CPU/Battery usage // Mobile and Desktop should avoid unnecessary background CPU/Battery usage
if (!this.isWails && !this.isCapacitor) { if (!this.isWails && !this.isCapacitor) {
this.statusInterval = setInterval(() => this.checkStatus(), 30000); this.statusInterval = setInterval(() => this.checkStatus(), 30000);
} }
window.addEventListener('online', () => this.checkStatus()); if (typeof window !== 'undefined') {
window.addEventListener('offline', () => { window.addEventListener('online', () => this.checkStatus());
this.isOnline = false; window.addEventListener('offline', () => {
this.ping = null; this.isOnline = false;
}); this.ping = null;
});
// Auto-refresh when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.isAuthenticated) {
this.refresh();
}
});
}
} }
async loadArticles() { async loadArticles() {
@@ -291,72 +305,79 @@ class NewsStore {
articles = await db.searchArticles(this.searchQuery, 100); articles = await db.searchArticles(this.searchQuery, 100);
this.hasMore = false; // Search results are usually limited this.hasMore = false; // Search results are usually limited
} else { } else {
if (this.currentView === 'saved') { if (this.currentView === 'saved') {
articles = await db.getSavedArticles(); articles = await db.getSavedArticles();
} else if (this.currentView === 'following') { } else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit); articles = await db.getArticles(undefined, 0, this.limit);
} else { } else {
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2); articles = await db.getArticles(
this.selectedFeedId || undefined,
0,
this.limit * 2,
this.selectedCategoryId || undefined
);
} }
} }
// Apply Mute Filters // Apply Mute Filters
if (this.settings.muteFilters && this.settings.muteFilters.length > 0) { if (this.settings.muteFilters && this.settings.muteFilters.length > 0) {
const filters = this.settings.muteFilters.map(f => f.toLowerCase()); const filters = this.settings.muteFilters.map((f) => f.toLowerCase());
articles = articles.filter(a => { articles = articles.filter((a) => {
const title = a.title.toLowerCase(); const title = a.title.toLowerCase();
return !filters.some(f => title.includes(f)); return !filters.some((f) => title.includes(f));
}); });
} }
if (!this.searchQuery) { if (!this.searchQuery) {
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) { if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
articles = this.rankArticles(articles); articles = this.rankArticles(articles);
} else { } else {
articles.sort((a, b) => b.pubDate - a.pubDate); articles.sort((a, b) => b.pubDate - a.pubDate);
} }
this.articles = articles.slice(0, this.limit); this.articles = articles.slice(0, this.limit);
if (articles.length < this.limit) { if (articles.length < this.limit) {
this.hasMore = false; this.hasMore = false;
} }
} else { } else {
this.articles = articles; this.articles = articles;
} }
this.lastArticlesUpdate = Date.now();
} }
private rankArticles(articles: Article[]): Article[] { private rankArticles(articles: Article[]): Article[] {
const now = Date.now(); const now = Date.now();
const profile = this.settings.relevanceProfile; const profile = this.settings.relevanceProfile;
const totalInteractions = profile.totalInteractions || 0; const totalInteractions = profile.totalInteractions || 0;
return articles.map(article => { return articles
const feed = this.feeds.find(f => f.id === article.feedId); .map((article) => {
const feedScore = profile.feedScores[article.feedId] || 0; const feed = this.feeds.find((f) => f.id === article.feedId);
const catScore = feed ? (profile.categoryScores[feed.categoryId] || 0) : 0; const feedScore = profile.feedScores[article.feedId] || 0;
const catScore = feed ? profile.categoryScores[feed.categoryId] || 0 : 0;
// Affinity score (0 to 1)
const affinity = totalInteractions > 0 // Affinity score (0 to 1)
? (feedScore + catScore) / (totalInteractions * 2) const affinity =
: 0; totalInteractions > 0 ? (feedScore + catScore) / (totalInteractions * 2) : 0;
// Recency score (decays over 48 hours) // Recency score (decays over 48 hours)
const ageHours = (now - article.pubDate) / (1000 * 60 * 60); const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
const recency = Math.max(0, 1 - (ageHours / 48)); const recency = Math.max(0, 1 - ageHours / 48);
// Weighted final score: 60% behavior, 40% recency // Weighted final score: 60% behavior, 40% recency
const score = (affinity * 0.6) + (recency * 0.4); const score = affinity * 0.6 + recency * 0.4;
return { ...article, relevanceScore: score }; return { ...article, relevanceScore: score };
}).sort((a: any, b: any) => b.relevanceScore - a.relevanceScore); })
.sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
} }
async trackInteraction(articleId: string, type: 'click' | 'save') { async trackInteraction(articleId: string, type: 'click' | 'save') {
if (!this.settings.smartFeed) return; if (!this.settings.smartFeed) return;
const article = this.articles.find(a => a.id === articleId); const article = this.articles.find((a) => a.id === articleId);
if (!article) return; if (!article) return;
const feed = this.feeds.find(f => f.id === article.feedId); const feed = this.feeds.find((f) => f.id === article.feedId);
const weight = type === 'save' ? 3 : 1; const weight = type === 'save' ? 3 : 1;
// Use snapshot to avoid reactive loops while updating profile // Use snapshot to avoid reactive loops while updating profile
@@ -364,7 +385,8 @@ class NewsStore {
profile.totalInteractions += weight; profile.totalInteractions += weight;
profile.feedScores[article.feedId] = (profile.feedScores[article.feedId] || 0) + weight; profile.feedScores[article.feedId] = (profile.feedScores[article.feedId] || 0) + weight;
if (feed && feed.categoryId) { if (feed && feed.categoryId) {
profile.categoryScores[feed.categoryId] = (profile.categoryScores[feed.categoryId] || 0) + weight; profile.categoryScores[feed.categoryId] =
(profile.categoryScores[feed.categoryId] || 0) + weight;
} }
this.settings.relevanceProfile = profile; this.settings.relevanceProfile = profile;
@@ -375,7 +397,7 @@ class NewsStore {
this.settings.relevanceProfile = { this.settings.relevanceProfile = {
categoryScores: {}, categoryScores: {},
feedScores: {}, feedScores: {},
totalInteractions: 0 totalInteractions: 0,
}; };
await db.saveSettings($state.snapshot(this.settings)); await db.saveSettings($state.snapshot(this.settings));
await this.loadArticles(); await this.loadArticles();
@@ -383,7 +405,11 @@ class NewsStore {
} }
async resetAllData() { async resetAllData() {
if (typeof window !== 'undefined' && !confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')) return; if (
typeof window !== 'undefined' &&
!confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')
)
return;
await db.clearAll(); await db.clearAll();
window.location.reload(); window.location.reload();
} }
@@ -391,18 +417,39 @@ class NewsStore {
async loadDemoData() { async loadDemoData() {
const demoCategories: Category[] = [ const demoCategories: Category[] = [
{ id: 'tech', name: 'Technology', order: 0 }, { id: 'tech', name: 'Technology', order: 0 },
{ id: 'news', name: 'General News', order: 1 } { id: 'news', name: 'General News', order: 1 },
]; ];
const demoFeeds: Partial<Feed>[] = [ const demoFeeds: Partial<Feed>[] = [
{ id: 'https://news.ycombinator.com/rss', title: 'Hacker News', categoryId: 'tech', order: 0, enabled: true, fetchInterval: 30 }, {
{ id: 'https://theverge.com/rss/index.xml', title: 'The Verge', categoryId: 'tech', order: 1, enabled: true, fetchInterval: 30 }, id: 'https://news.ycombinator.com/rss',
{ id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', title: 'NYT Top Stories', categoryId: 'news', order: 0, enabled: true, fetchInterval: 30 } title: 'Hacker News',
categoryId: 'tech',
order: 0,
enabled: true,
fetchInterval: 30,
},
{
id: 'https://theverge.com/rss/index.xml',
title: 'The Verge',
categoryId: 'tech',
order: 1,
enabled: true,
fetchInterval: 30,
},
{
id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml',
title: 'NYT Top Stories',
categoryId: 'news',
order: 0,
enabled: true,
fetchInterval: 30,
},
]; ];
await db.saveCategories(demoCategories); await db.saveCategories(demoCategories);
await db.saveFeeds(demoFeeds as Feed[]); await db.saveFeeds(demoFeeds as Feed[]);
toast.success('Demo data loaded'); toast.success('Demo data loaded');
await this.init(); await this.init();
} }
@@ -410,14 +457,19 @@ class NewsStore {
async loadMore() { async loadMore() {
if (this.loading || !this.hasMore) return; if (this.loading || !this.hasMore) return;
this.loading = true; this.loading = true;
let more: Article[] = []; let more: Article[] = [];
const offset = this.articles.length; const offset = this.articles.length;
if (this.currentView === 'saved') { if (this.currentView === 'saved') {
this.hasMore = false; this.hasMore = false;
} else { } else {
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit); more = await db.getArticles(
this.selectedFeedId || undefined,
offset,
this.limit,
this.selectedCategoryId || undefined
);
} }
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) { if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
@@ -430,9 +482,8 @@ class NewsStore {
if (this.searchQuery) { if (this.searchQuery) {
const query = this.searchQuery.toLowerCase(); const query = this.searchQuery.toLowerCase();
more = more.filter(a => more = more.filter(
a.title.toLowerCase().includes(query) || (a) => a.title.toLowerCase().includes(query) || a.description.toLowerCase().includes(query)
a.description.toLowerCase().includes(query)
); );
} }
@@ -443,22 +494,31 @@ class NewsStore {
async selectView(view: 'all' | 'saved' | 'following') { async selectView(view: 'all' | 'saved' | 'following') {
this.currentView = view; this.currentView = view;
this.selectedFeedId = null; this.selectedFeedId = null;
this.selectedCategoryId = null;
await this.loadArticles(); await this.loadArticles();
} }
async selectFeed(feedId: string | null) { async selectFeed(feedId: string | null) {
this.selectedFeedId = feedId; this.selectedFeedId = feedId;
this.selectedCategoryId = null;
this.currentView = 'all';
await this.loadArticles();
}
async selectCategory(categoryId: string | null) {
this.selectedCategoryId = categoryId;
this.selectedFeedId = null;
this.currentView = 'all'; this.currentView = 'all';
await this.loadArticles(); await this.loadArticles();
} }
async toggleSave(articleId: string) { async toggleSave(articleId: string) {
const isSaved = await db.toggleSave(articleId); const isSaved = await db.toggleSave(articleId);
const article = this.articles.find(a => a.id === articleId); const article = this.articles.find((a) => a.id === articleId);
if (article) article.saved = isSaved; if (article) article.saved = isSaved;
if (this.currentView === 'saved' && !isSaved) { if (this.currentView === 'saved' && !isSaved) {
this.articles = this.articles.filter(a => a.id !== articleId); this.articles = this.articles.filter((a) => a.id !== articleId);
} }
return isSaved; return isSaved;
} }
@@ -467,7 +527,7 @@ class NewsStore {
async bulkMarkRead() { async bulkMarkRead() {
const ids = Array.from(this.selectedArticleIds); const ids = Array.from(this.selectedArticleIds);
await db.bulkMarkRead(ids); await db.bulkMarkRead(ids);
this.articles.forEach(a => { this.articles.forEach((a) => {
if (this.selectedArticleIds.has(a.id)) a.read = true; if (this.selectedArticleIds.has(a.id)) a.read = true;
}); });
this.selectedArticleIds.clear(); this.selectedArticleIds.clear();
@@ -478,7 +538,7 @@ class NewsStore {
async bulkToggleSave() { async bulkToggleSave() {
const ids = Array.from(this.selectedArticleIds); const ids = Array.from(this.selectedArticleIds);
await db.bulkToggleSave(ids); await db.bulkToggleSave(ids);
this.articles.forEach(a => { this.articles.forEach((a) => {
if (this.selectedArticleIds.has(a.id)) a.saved = !a.saved; if (this.selectedArticleIds.has(a.id)) a.saved = !a.saved;
}); });
this.selectedArticleIds.clear(); this.selectedArticleIds.clear();
@@ -490,7 +550,7 @@ class NewsStore {
if (!confirm('Are you sure you want to delete these articles?')) return; if (!confirm('Are you sure you want to delete these articles?')) return;
const ids = Array.from(this.selectedArticleIds); const ids = Array.from(this.selectedArticleIds);
await db.bulkDelete(ids); await db.bulkDelete(ids);
this.articles = this.articles.filter(a => !this.selectedArticleIds.has(a.id)); this.articles = this.articles.filter((a) => !this.selectedArticleIds.has(a.id));
this.selectedArticleIds.clear(); this.selectedArticleIds.clear();
this.isSelectMode = false; this.isSelectMode = false;
toast.success(`Deleted ${ids.length} articles`); toast.success(`Deleted ${ids.length} articles`);
@@ -503,7 +563,7 @@ class NewsStore {
this.readingArticle = this.articles[0]; this.readingArticle = this.articles[0];
return; return;
} }
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id); const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
if (idx < this.articles.length - 1) { if (idx < this.articles.length - 1) {
this.readingArticle = this.articles[idx + 1]; this.readingArticle = this.articles[idx + 1];
} }
@@ -511,24 +571,34 @@ class NewsStore {
prevArticle() { prevArticle() {
if (!this.readingArticle) return; if (!this.readingArticle) return;
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id); const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
if (idx > 0) { if (idx > 0) {
this.readingArticle = this.articles[idx - 1]; this.readingArticle = this.articles[idx - 1];
} }
} }
async refresh() { async refresh() {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
if (this.loading || !this.isAuthenticated) return; if (this.loading || !this.isAuthenticated) return;
this.loading = true; this.loading = true;
this.refreshController = new AbortController();
try { try {
await refreshAllFeeds(); await refreshAllFeeds(this.refreshController.signal);
await this.loadArticles(); await this.loadArticles();
this.feeds = (await db.getFeeds()) || []; this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || []; this.categories = (await db.getCategories()) || [];
// Fix orphans: if a feed has a categoryId that doesn't exist, move it to the first category // Fix orphans: if a feed has a categoryId that doesn't exist, move it to the first category
if (this.categories.length > 0) { if (this.categories.length > 0) {
const catIds = new Set(this.categories.map(c => c.id)); const catIds = new Set(this.categories.map((c) => c.id));
for (const f of this.feeds) { for (const f of this.feeds) {
if (!catIds.has(f.categoryId)) { if (!catIds.has(f.categoryId)) {
f.categoryId = this.categories[0].id; f.categoryId = this.categories[0].id;
@@ -538,7 +608,11 @@ class NewsStore {
} }
toast.success('Feeds refreshed'); toast.success('Feeds refreshed');
} catch (e) { } catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Refresh failed:', e); console.error('Refresh failed:', e);
if (e instanceof Error && e.message.includes('401')) { if (e instanceof Error && e.message.includes('401')) {
this.logout(); this.logout();
@@ -547,13 +621,44 @@ class NewsStore {
} }
} finally { } finally {
this.loading = false; this.loading = false;
this.refreshController = null;
}
}
async refreshFeed(feedId: string) {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshFeed(feedId, this.refreshController.signal);
this.feeds = await db.getFeeds();
await this.loadArticles();
toast.success('Feed refreshed');
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Feed refresh failed:', e);
toast.error('Failed to refresh feed');
this.feeds = await db.getFeeds();
} finally {
this.loading = false;
this.refreshController = null;
} }
} }
async fetchFullText(url: string, articleId?: string) { async fetchFullText(url: string, articleId?: string) {
// 1. Check local cache first // 1. Check local cache first
if (articleId) { if (articleId) {
const cached = this.articles.find(a => a.id === articleId); const cached = this.articles.find((a) => a.id === articleId);
if (cached?.content) { if (cached?.content) {
return { return {
title: cached.title, title: cached.title,
@@ -570,7 +675,9 @@ class NewsStore {
if (this.settings.authToken) { if (this.settings.authToken) {
headers['X-Account-Number'] = this.settings.authToken; headers['X-Account-Number'] = this.settings.authToken;
} }
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, { headers }); const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, {
headers,
});
if (response.status === 401) { if (response.status === 401) {
this.logout(); this.logout();
throw new Error('401'); throw new Error('401');
@@ -582,13 +689,14 @@ class NewsStore {
if (articleId) { if (articleId) {
// Mark as read when opening // Mark as read when opening
await db.markAsRead(articleId); await db.markAsRead(articleId);
const art = this.articles.find(a => a.id === articleId); const art = this.articles.find((a) => a.id === articleId);
if (art) { if (art) {
art.read = true; art.read = true;
art.readAt = Date.now(); art.readAt = Date.now();
if (data.content) art.content = data.content; if (data.content) art.content = data.content;
data.feedId = art.feedId;
} }
if (data.content) { if (data.content) {
await db.updateArticleContent(articleId, data.content); await db.updateArticleContent(articleId, data.content);
} }
@@ -611,20 +719,41 @@ class NewsStore {
async deleteFeed(id: string) { async deleteFeed(id: string) {
await db.deleteFeed(id); await db.deleteFeed(id);
this.feeds = this.feeds.filter(f => f.id !== id); this.feeds = this.feeds.filter((f) => f.id !== id);
if (this.selectedFeedId === id) this.selectFeed(null); if (this.selectedFeedId === id) this.selectFeed(null);
toast.success('Feed removed'); toast.success('Feed removed');
} }
async purgeProblematicFeeds(threshold = 5) {
const problematic = this.feeds.filter((f) => f.consecutiveErrors >= threshold);
if (problematic.length === 0) {
toast.info('No problematic feeds found');
return;
}
if (
!confirm(
`Are you sure you want to remove ${problematic.length} feeds that have failed ${threshold}+ times?`
)
)
return;
for (const feed of problematic) {
await db.deleteFeed(feed.id);
}
this.feeds = this.feeds.filter((f) => f.consecutiveErrors < threshold);
toast.success(`Removed ${problematic.length} problematic feeds`);
}
async updateFeed(feed: Feed, oldId?: string) { async updateFeed(feed: Feed, oldId?: string) {
const plainFeed = $state.snapshot(feed); const plainFeed = $state.snapshot(feed);
if (oldId && oldId !== feed.id) { if (oldId && oldId !== feed.id) {
await db.deleteFeed(oldId); await db.deleteFeed(oldId);
} }
await db.saveFeed(plainFeed); await db.saveFeed(plainFeed);
const searchId = oldId || feed.id; const searchId = oldId || feed.id;
const index = this.feeds.findIndex(f => f.id === searchId); const index = this.feeds.findIndex((f) => f.id === searchId);
if (index !== -1) { if (index !== -1) {
this.feeds[index] = plainFeed; this.feeds[index] = plainFeed;
} else { } else {
@@ -636,7 +765,7 @@ class NewsStore {
async reorderFeeds(feedIds: string[]) { async reorderFeeds(feedIds: string[]) {
const updatedFeeds = $state.snapshot(this.feeds); const updatedFeeds = $state.snapshot(this.feeds);
feedIds.forEach((id, index) => { feedIds.forEach((id, index) => {
const feed = updatedFeeds.find(f => f.id === id); const feed = updatedFeeds.find((f) => f.id === id);
if (feed) feed.order = index; if (feed) feed.order = index;
}); });
await db.saveFeeds(updatedFeeds); await db.saveFeeds(updatedFeeds);
@@ -655,7 +784,7 @@ class NewsStore {
async updateCategory(category: Category) { async updateCategory(category: Category) {
const plainCategory = $state.snapshot(category); const plainCategory = $state.snapshot(category);
await db.saveCategory(plainCategory); await db.saveCategory(plainCategory);
const index = this.categories.findIndex(c => c.id === category.id); const index = this.categories.findIndex((c) => c.id === category.id);
if (index !== -1) this.categories[index] = plainCategory; if (index !== -1) this.categories[index] = plainCategory;
toast.success('Category updated'); toast.success('Category updated');
} }
@@ -668,18 +797,18 @@ class NewsStore {
if (!confirm(`Are you sure? Feeds in this category will be moved.`)) return; if (!confirm(`Are you sure? Feeds in this category will be moved.`)) return;
const fallbackCat = this.categories.find(c => c.id !== id); const fallbackCat = this.categories.find((c) => c.id !== id);
if (!fallbackCat) return; if (!fallbackCat) return;
// Move feeds to fallback category first // Move feeds to fallback category first
const feedsToMove = this.feeds.filter(f => f.categoryId === id); const feedsToMove = this.feeds.filter((f) => f.categoryId === id);
for (const f of feedsToMove) { for (const f of feedsToMove) {
const updatedFeed = { ...$state.snapshot(f), categoryId: fallbackCat.id }; const updatedFeed = { ...$state.snapshot(f), categoryId: fallbackCat.id };
await db.saveFeed(updatedFeed); await db.saveFeed(updatedFeed);
} }
await db.deleteCategory(id); await db.deleteCategory(id);
this.categories = this.categories.filter(c => c.id !== id); this.categories = this.categories.filter((c) => c.id !== id);
this.feeds = await db.getFeeds(); this.feeds = await db.getFeeds();
toast.success('Category removed'); toast.success('Category removed');
} }
@@ -687,7 +816,7 @@ class NewsStore {
async reorderCategories(catIds: string[]) { async reorderCategories(catIds: string[]) {
const updatedCats = $state.snapshot(this.categories); const updatedCats = $state.snapshot(this.categories);
catIds.forEach((id, index) => { catIds.forEach((id, index) => {
const cat = updatedCats.find(c => c.id === id); const cat = updatedCats.find((c) => c.id === id);
if (cat) cat.order = index; if (cat) cat.order = index;
}); });
await db.saveCategories(updatedCats); await db.saveCategories(updatedCats);
@@ -697,7 +826,9 @@ class NewsStore {
applyTheme() { applyTheme() {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const theme = this.settings.theme; const theme = this.settings.theme;
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark); document.documentElement.classList.toggle('dark', isDark);
} }
@@ -712,7 +843,9 @@ class NewsStore {
startAutoFetch() { startAutoFetch() {
if (this.fetchInterval) clearInterval(this.fetchInterval); if (this.fetchInterval) clearInterval(this.fetchInterval);
this.fetchInterval = setInterval(() => { this.fetchInterval = setInterval(() => {
this.refresh(); if (this.isAuthenticated) {
this.refresh();
}
}, this.settings.globalFetchInterval * 60000); }, this.settings.globalFetchInterval * 60000);
} }
} }

View File

@@ -23,14 +23,21 @@ class ToastStore {
} }
remove(id: string) { remove(id: string) {
this.toasts = this.toasts.filter(t => t.id !== id); this.toasts = this.toasts.filter((t) => t.id !== id);
} }
success(message: string) { this.add(message, 'success'); } success(message: string) {
error(message: string) { this.add(message, 'error', 5000); } this.add(message, 'success');
info(message: string) { this.add(message, 'info'); } }
warning(message: string) { this.add(message, 'warning'); } error(message: string) {
this.add(message, 'error', 5000);
}
info(message: string) {
this.add(message, 'info');
}
warning(message: string) {
this.add(message, 'warning');
}
} }
export const toast = new ToastStore(); export const toast = new ToastStore();

View File

@@ -77,7 +77,9 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-text-primary">Update Available</p> <p class="text-sm font-medium text-text-primary">Update Available</p>
<p class="text-xs text-text-secondary mt-1">A new version is available. Reload to update.</p> <p class="text-xs text-text-secondary mt-1">
A new version is available. Reload to update.
</p>
</div> </div>
<button <button
on:click={reloadApp} on:click={reloadApp}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,13 @@
import { newsStore } from '$lib/store.svelte'; import { newsStore } from '$lib/store.svelte';
import Navbar from '../../components/Navbar.svelte'; import Navbar from '../../components/Navbar.svelte';
let article = $state<{ title: string, link: string, description: string, imageUrl?: string, content?: string } | null>(null); let article = $state<{
title: string;
link: string;
description: string;
imageUrl?: string;
content?: string;
} | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
@@ -15,18 +21,18 @@
const url = atob(encodedUrl); const url = atob(encodedUrl);
const apiBase = newsStore.settings.apiBaseUrl || '/api'; const apiBase = newsStore.settings.apiBaseUrl || '/api';
const fullTextUrl = `${apiBase}/fulltext?url=${encodeURIComponent(url)}`; const fullTextUrl = `${apiBase}/fulltext?url=${encodeURIComponent(url)}`;
const response = await fetch(fullTextUrl); const response = await fetch(fullTextUrl);
if (!response.ok) throw new Error('Failed to fetch article content'); if (!response.ok) throw new Error('Failed to fetch article content');
const data = await response.json(); const data = await response.json();
article = { article = {
title: data.title || url, title: data.title || url,
link: url, link: url,
description: data.excerpt || '', description: data.excerpt || '',
imageUrl: data.image || '', imageUrl: data.image || '',
content: data.content || '' content: data.content || '',
}; };
} catch (e: any) { } catch (e: any) {
error = 'Failed to load article: ' + e.message; error = 'Failed to load article: ' + e.message;
@@ -57,11 +63,41 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{article ? article.title : 'Web News - Shared Article'}</title> <title>{article ? article.title : 'Shared Article on Webnews'}</title>
<meta
name="description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="article" />
<meta property="og:title" content={article ? article.title : 'Shared Article on Webnews'} />
<meta
property="og:description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
{#if article?.imageUrl}
<meta property="og:image" content={article.imageUrl} />
{:else}
<meta property="og:image" content="/favicon.svg" />
{/if}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={article ? article.title : 'Shared Article on Webnews'} />
<meta
property="twitter:description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
{#if article?.imageUrl}
<meta property="twitter:image" content={article.imageUrl} />
{:else}
<meta property="twitter:image" content="/favicon.svg" />
{/if}
</svelte:head> </svelte:head>
<div class="min-h-screen bg-bg-primary text-text-primary flex flex-col"> <div class="min-h-screen bg-bg-primary text-text-primary flex flex-col">
<Navbar onAddFeed={() => window.location.href = '/'} /> <Navbar onAddFeed={() => (window.location.href = '/')} />
<main class="flex-1 p-4 lg:p-8 overflow-y-auto"> <main class="flex-1 p-4 lg:p-8 overflow-y-auto">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
@@ -80,7 +116,7 @@
</div> </div>
{:else if article} {:else if article}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<a <a
href="/" href="/"
class="flex items-center gap-2 text-text-secondary hover:text-accent-blue transition-colors mb-4" class="flex items-center gap-2 text-text-secondary hover:text-accent-blue transition-colors mb-4"
> >
@@ -90,9 +126,13 @@
<div class="card p-6 sm:p-8 space-y-6"> <div class="card p-6 sm:p-8 space-y-6">
{#if article.imageUrl} {#if article.imageUrl}
<img src={article.imageUrl} alt="" class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg" /> <img
src={article.imageUrl}
alt=""
class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg"
/>
{/if} {/if}
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight tracking-tight"> <h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight tracking-tight">
{article.title} {article.title}
@@ -109,9 +149,9 @@
</div> </div>
<div class="pt-8 border-t border-border-color flex flex-wrap gap-4"> <div class="pt-8 border-t border-border-color flex flex-wrap gap-4">
<a <a
href={article.link} href={article.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="btn-primary flex items-center gap-3 px-8 py-4 text-lg font-bold rounded-2xl" class="btn-primary flex items-center gap-3 px-8 py-4 text-lg font-bold rounded-2xl"
> >
@@ -133,7 +173,9 @@
:global(.prose img) { :global(.prose img) {
border-radius: 1rem; border-radius: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
} }
:global(.prose) { :global(.prose) {
--tw-prose-body: var(--text-secondary); --tw-prose-body: var(--text-secondary);
@@ -143,4 +185,3 @@
--tw-prose-quotes: var(--text-primary); --tw-prose-quotes: var(--text-primary);
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/> <path d="M4 11a9 9 0 0 1 9 9"/>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M4 4a16 16 0 0 1 16 16"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <circle cx="5" cy="19" r="1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -1,4 +1,4 @@
const CACHE_VERSION = '0.1.0'; const CACHE_VERSION = '0.2.3';
const CACHE_NAME = `web-news-${CACHE_VERSION}`; const CACHE_NAME = `web-news-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json']; const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
@@ -45,7 +45,7 @@ self.addEventListener('fetch', (event) => {
} }
// Network-First Strategy for everything else // Network-First Strategy for everything else
// This ensures you always see the latest version if online, // This ensures you always see the latest version if online,
// and only see the cached version if truly offline. // and only see the cached version if truly offline.
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request)

View File

@@ -9,9 +9,9 @@ const config = {
assets: 'build', assets: 'build',
fallback: 'index.html', fallback: 'index.html',
precompress: false, precompress: false,
strict: true strict: true,
}) }),
} },
}; };
export default config; export default config;

View File

@@ -11,9 +11,9 @@ const config = {
assets: 'build', assets: 'build',
fallback: 'index.html', fallback: 'index.html',
precompress: false, precompress: false,
strict: true strict: true,
}) }),
} },
}; };
export default config; export default config;

View File

@@ -20,7 +20,5 @@ export default {
}, },
}, },
}, },
plugins: [ plugins: [typography],
typography,
],
}; };

View File

@@ -15,7 +15,7 @@ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8080' '/api': 'http://localhost:8080',
} },
} },
}); });