0.1.0
45
.air.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main main.go"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "node_modules", ".svelte-kit"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
|
||||
38
.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
|
||||
# SvelteKit & Vite
|
||||
.svelte-kit/
|
||||
build/
|
||||
dist/
|
||||
.output/
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Go & Binaries
|
||||
bin/
|
||||
web-news
|
||||
tmp/
|
||||
|
||||
# Wails Desktop
|
||||
desktop/
|
||||
|
||||
# Android & Capacitor
|
||||
android/
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE & OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# App Data & Secrets
|
||||
accounts.json
|
||||
client_hashes.json
|
||||
.env
|
||||
.env.*
|
||||
39
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Frontend checks
|
||||
run: bash scripts/check.sh
|
||||
- name: Build frontend
|
||||
run: bash scripts/build.sh
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.4'
|
||||
- name: Build backend
|
||||
run: |
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/web-news main.go
|
||||
64
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
REGISTRY: git.quad4.io
|
||||
IMAGE_NAME: quad4-software/linking-tool
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image_digest: ${{ steps.build.outputs.digest }}
|
||||
image_tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
37
.gitea/workflows/npm-publish.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
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 }}
|
||||
|
||||
20
.gitea/workflows/osv-pr.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
name: OSV-Scanner PR Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
merge_group:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: OSV scan
|
||||
run: bash scripts/osv_scan.sh
|
||||
20
.gitea/workflows/osv-scheduled.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
name: OSV-Scanner Scheduled Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 12 * * 1'
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan-scheduled:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: OSV scan
|
||||
run: bash scripts/osv_scan.sh
|
||||
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
|
||||
# SvelteKit & Vite
|
||||
.svelte-kit/
|
||||
build/
|
||||
dist/
|
||||
.output/
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
*.md5
|
||||
|
||||
# Go & Binaries
|
||||
bin/
|
||||
web-news
|
||||
tmp/
|
||||
|
||||
# Wails Desktop
|
||||
desktop/frontend_dist/
|
||||
desktop/build/bin/
|
||||
desktop/build/
|
||||
wailsjs/
|
||||
|
||||
# IDE & OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# App Data & Secrets
|
||||
accounts.json
|
||||
client_hashes.json
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Android & Capacitor
|
||||
android/app/build/
|
||||
android/build/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
@quad4:registry=https://git.quad4.io/api/packages/quad4-software/npm/
|
||||
//git.quad4.io/api/packages/quad4-software/npm/:_authToken=${NPM_TOKEN}
|
||||
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Stage 1: Build the frontend
|
||||
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY --chown=node:node . .
|
||||
COPY --chown=node:node svelte.config.docker.js svelte.config.js
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build the Go binary with embedded assets
|
||||
FROM cgr.dev/chainguard/go:latest-dev AS go-builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=node-builder /app/build ./build
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go
|
||||
|
||||
# Stage 3: Minimal runtime image
|
||||
FROM cgr.dev/chainguard/wolfi-base:latest
|
||||
WORKDIR /app
|
||||
COPY --from=go-builder /app/web-news .
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
EXPOSE 8080
|
||||
ENV PORT=8080
|
||||
ENV NODE_ENV=production
|
||||
|
||||
USER 65532
|
||||
|
||||
CMD ["./web-news"]
|
||||
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Quad4.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
90
Makefile
Normal file
@@ -0,0 +1,90 @@
|
||||
BINARY_NAME=web-news
|
||||
BUILD_DIR=bin
|
||||
|
||||
.PHONY: help install dev build preview check lint format clean docker-build docker-run release build-linux-amd64 build-linux-arm64 build-linux-armv6 build-linux-armv7 build-windows-amd64 build-darwin-amd64 build-darwin-arm64 build-freebsd-amd64 android-build
|
||||
|
||||
help:
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Available targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
android-build: build
|
||||
npx cap sync android
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk && cd android && ./gradlew assembleDebug
|
||||
mkdir -p $(BUILD_DIR)/android
|
||||
cp android/app/build/outputs/apk/debug/app-debug.apk $(BUILD_DIR)/android/web-news-debug.apk
|
||||
|
||||
dev:
|
||||
npm install
|
||||
(command -v air > /dev/null && air || go run main.go & npm run dev)
|
||||
|
||||
build:
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) main.go
|
||||
|
||||
release: build
|
||||
mkdir -p $(BUILD_DIR)
|
||||
@$(MAKE) build-linux-amd64
|
||||
@$(MAKE) build-linux-arm64
|
||||
@$(MAKE) build-linux-armv6
|
||||
@$(MAKE) build-linux-armv7
|
||||
@$(MAKE) build-windows-amd64
|
||||
@$(MAKE) build-darwin-amd64
|
||||
@$(MAKE) build-darwin-arm64
|
||||
@$(MAKE) build-freebsd-amd64
|
||||
|
||||
build-linux-amd64:
|
||||
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 main.go
|
||||
|
||||
build-linux-arm64:
|
||||
GOOS=linux GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 main.go
|
||||
|
||||
build-linux-armv6:
|
||||
GOOS=linux GOARCH=arm GOARM=6 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv6 main.go
|
||||
|
||||
build-linux-armv7:
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 main.go
|
||||
|
||||
build-windows-amd64:
|
||||
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe main.go
|
||||
|
||||
build-darwin-amd64:
|
||||
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 main.go
|
||||
|
||||
build-darwin-arm64:
|
||||
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 main.go
|
||||
|
||||
build-freebsd-amd64:
|
||||
GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go
|
||||
|
||||
docker-build:
|
||||
docker build -t $(BINARY_NAME) .
|
||||
|
||||
docker-run:
|
||||
docker run -p 8080:8080 $(BINARY_NAME)
|
||||
|
||||
desktop-build: build
|
||||
rm -rf desktop/frontend_dist/*
|
||||
cp -r build/* desktop/frontend_dist/
|
||||
cd desktop && wails build
|
||||
|
||||
desktop-windows: build
|
||||
rm -rf desktop/frontend_dist/*
|
||||
cp -r build/* desktop/frontend_dist/
|
||||
cd desktop && wails build -platform windows/amd64
|
||||
|
||||
desktop-darwin: build
|
||||
rm -rf desktop/frontend_dist/*
|
||||
cp -r build/* desktop/frontend_dist/
|
||||
cd desktop && wails build -platform darwin/universal
|
||||
|
||||
desktop-dev: build
|
||||
rm -rf desktop/frontend_dist/*
|
||||
cp -r build/* desktop/frontend_dist/
|
||||
cd desktop && wails dev
|
||||
|
||||
clean:
|
||||
rm -rf .svelte-kit build node_modules/.vite dist package web-news tmp $(BUILD_DIR) android/app/build android/build
|
||||
101
android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
android/app/build.gradle
Normal file
@@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "com.quad4.webnews"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.quad4.webnews"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
19
android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
24
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Capacitor specific rules
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class com.quad4.webnews.** { *; }
|
||||
|
||||
# SQLite Plugin specific rules
|
||||
-keep class net.sqlcipher.** { *; }
|
||||
-keep class net.sqlcipher.database.** { *; }
|
||||
-keep class com.getcapacitor.community.database.sqlite.** { *; }
|
||||
|
||||
# Maintain line numbers for easier debugging
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
# Gson/JSON serialization preservation
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.google.gson.reflect.TypeToken { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
-keep class com.google.gson.** { *; }
|
||||
|
||||
# AndroidX and other support libraries
|
||||
-keep class androidx.** { *; }
|
||||
-dontwarn androidx.**
|
||||
-keep interface androidx.** { *; }
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
45
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Required for background sync features -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.quad4.webnews;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
297
android/app/src/main/java/com/quad4/webnews/RSSPlugin.java
Normal file
@@ -0,0 +1,297 @@
|
||||
package com.quad4.webnews;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import android.util.Log;
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
import org.xmlpull.v1.XmlPullParserFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
@CapacitorPlugin(name = "RSS")
|
||||
public class RSSPlugin extends Plugin {
|
||||
|
||||
@PluginMethod
|
||||
public void fetchFeed(PluginCall call) {
|
||||
String url = call.getString("url");
|
||||
if (url == null) {
|
||||
call.reject("URL is required");
|
||||
return;
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
JSObject result = performFetch(url);
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
Log.e("RSSPlugin", "Fetch failed for " + url, e);
|
||||
call.reject("Failed to fetch/parse feed: " + e.getMessage());
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private JSObject performFetch(String urlString) throws IOException, XmlPullParserException {
|
||||
URL url = new URL(urlString);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
conn.setRequestProperty("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");
|
||||
conn.setRequestProperty("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7");
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("Server returned status " + responseCode);
|
||||
}
|
||||
|
||||
String contentType = conn.getContentType();
|
||||
if (contentType != null && contentType.contains("text/html")) {
|
||||
throw new IOException("Server returned HTML instead of RSS. The feed URL might be wrong or you might be blocked.");
|
||||
}
|
||||
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
return parseFeed(in, urlString);
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private JSObject parseFeed(InputStream in, String feedUrl) throws XmlPullParserException, IOException {
|
||||
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
XmlPullParser xpp = factory.newPullParser();
|
||||
xpp.setInput(in, null);
|
||||
|
||||
JSObject feedInfo = new JSObject();
|
||||
JSArray articles = new JSArray();
|
||||
|
||||
feedInfo.put("lastFetched", System.currentTimeMillis());
|
||||
|
||||
int eventType = xpp.getEventType();
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG) {
|
||||
String tagName = xpp.getName();
|
||||
if (tagName.equalsIgnoreCase("feed")) {
|
||||
parseAtom(xpp, feedInfo, articles, feedUrl);
|
||||
break;
|
||||
} else if (tagName.equalsIgnoreCase("rss") || tagName.equalsIgnoreCase("rdf")) {
|
||||
parseRSS(xpp, feedInfo, articles, feedUrl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("feed", feedInfo);
|
||||
result.put("articles", articles);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void parseRSS(XmlPullParser xpp, JSObject feedInfo, JSArray articles, String feedUrl) throws XmlPullParserException, IOException {
|
||||
int eventType = xpp.getEventType();
|
||||
boolean inChannel = false;
|
||||
JSObject currentItem = null;
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
String name = xpp.getName();
|
||||
if (eventType == XmlPullParser.START_TAG && name != null) {
|
||||
if (name.equalsIgnoreCase("channel")) {
|
||||
inChannel = true;
|
||||
} else if (name.equalsIgnoreCase("item")) {
|
||||
currentItem = new JSObject();
|
||||
currentItem.put("feedId", feedUrl);
|
||||
currentItem.put("read", false);
|
||||
currentItem.put("saved", false);
|
||||
} else if (currentItem != null) {
|
||||
if (name.equalsIgnoreCase("title")) currentItem.put("title", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("link")) currentItem.put("link", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("description")) currentItem.put("description", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("guid")) currentItem.put("id", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("pubDate")) {
|
||||
currentItem.put("pubDate", parseDate(safeNextText(xpp)));
|
||||
}
|
||||
else if (name.equalsIgnoreCase("creator") || name.equalsIgnoreCase("author")) {
|
||||
currentItem.put("author", safeNextText(xpp));
|
||||
}
|
||||
else if (name.equalsIgnoreCase("enclosure") || name.equalsIgnoreCase("content")) {
|
||||
String type = xpp.getAttributeValue(null, "type");
|
||||
String url = xpp.getAttributeValue(null, "url");
|
||||
if (url == null) url = xpp.getAttributeValue(null, "href");
|
||||
|
||||
if (url != null && (type == null || type.startsWith("image/"))) {
|
||||
currentItem.put("imageUrl", url);
|
||||
}
|
||||
}
|
||||
else if (name.equalsIgnoreCase("thumbnail")) {
|
||||
currentItem.put("imageUrl", xpp.getAttributeValue(null, "url"));
|
||||
}
|
||||
} else if (inChannel) {
|
||||
if (name.equalsIgnoreCase("title")) feedInfo.put("title", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("link")) feedInfo.put("siteUrl", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("description")) feedInfo.put("description", safeNextText(xpp));
|
||||
}
|
||||
} else if (eventType == XmlPullParser.END_TAG && name != null) {
|
||||
if (name.equalsIgnoreCase("item")) {
|
||||
if (currentItem != null) {
|
||||
if (currentItem.getString("id") == null) {
|
||||
currentItem.put("id", currentItem.getString("link"));
|
||||
}
|
||||
articles.put(currentItem);
|
||||
currentItem = null;
|
||||
}
|
||||
} else if (name.equalsIgnoreCase("channel")) {
|
||||
inChannel = false;
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
}
|
||||
|
||||
private void parseAtom(XmlPullParser xpp, JSObject feedInfo, JSArray articles, String feedUrl) throws XmlPullParserException, IOException {
|
||||
int eventType = xpp.getEventType();
|
||||
JSObject currentEntry = null;
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
String name = xpp.getName();
|
||||
if (eventType == XmlPullParser.START_TAG && name != null) {
|
||||
if (name.equalsIgnoreCase("entry")) {
|
||||
currentEntry = new JSObject();
|
||||
currentEntry.put("feedId", feedUrl);
|
||||
currentEntry.put("read", false);
|
||||
currentEntry.put("saved", false);
|
||||
} else if (currentEntry != null) {
|
||||
if (name.equalsIgnoreCase("title")) currentEntry.put("title", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("link")) {
|
||||
String rel = xpp.getAttributeValue(null, "rel");
|
||||
if (rel == null || rel.equals("alternate")) {
|
||||
currentEntry.put("link", xpp.getAttributeValue(null, "href"));
|
||||
}
|
||||
}
|
||||
else if (name.equalsIgnoreCase("summary") || name.equalsIgnoreCase("content")) {
|
||||
currentEntry.put("description", safeNextText(xpp));
|
||||
}
|
||||
else if (name.equalsIgnoreCase("id")) currentEntry.put("id", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("published") || name.equalsIgnoreCase("updated")) {
|
||||
currentEntry.put("pubDate", parseDate(safeNextText(xpp)));
|
||||
}
|
||||
else if (name.equalsIgnoreCase("link")) {
|
||||
String rel = xpp.getAttributeValue(null, "rel");
|
||||
String type = xpp.getAttributeValue(null, "type");
|
||||
String href = xpp.getAttributeValue(null, "href");
|
||||
if (rel != null && rel.equals("enclosure") && type != null && type.startsWith("image/")) {
|
||||
currentEntry.put("imageUrl", href);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (name.equalsIgnoreCase("title")) feedInfo.put("title", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("subtitle")) feedInfo.put("description", safeNextText(xpp));
|
||||
else if (name.equalsIgnoreCase("link")) {
|
||||
String rel = xpp.getAttributeValue(null, "rel");
|
||||
if (rel == null || rel.equals("alternate")) {
|
||||
feedInfo.put("siteUrl", xpp.getAttributeValue(null, "href"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (eventType == XmlPullParser.END_TAG && name != null) {
|
||||
if (name.equalsIgnoreCase("entry")) {
|
||||
if (currentEntry != null) {
|
||||
if (currentEntry.getString("id") == null) {
|
||||
currentEntry.put("id", currentEntry.getString("link"));
|
||||
}
|
||||
articles.put(currentEntry);
|
||||
currentEntry = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void fetchRawHtml(PluginCall call) {
|
||||
String urlString = call.getString("url");
|
||||
if (urlString == null) {
|
||||
call.reject("URL is required");
|
||||
return;
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
URL url = new URL(urlString);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
conn.setRequestProperty("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");
|
||||
conn.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("Server returned status " + responseCode);
|
||||
}
|
||||
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
java.util.Scanner s = new java.util.Scanner(in).useDelimiter("\\A");
|
||||
String html = s.hasNext() ? s.next() : "";
|
||||
JSObject result = new JSObject();
|
||||
result.put("html", html);
|
||||
call.resolve(result);
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
call.reject("Failed to fetch HTML: " + e.getMessage());
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String safeNextText(XmlPullParser xpp) throws XmlPullParserException, IOException {
|
||||
try {
|
||||
return xpp.nextText();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private long parseDate(String dateStr) {
|
||||
if (dateStr == null || dateStr.isEmpty()) return System.currentTimeMillis();
|
||||
|
||||
String[] formats = {
|
||||
"EEE, dd MMM yyyy HH:mm:ss Z",
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
"yyyy-MM-dd"
|
||||
};
|
||||
|
||||
for (String format : formats) {
|
||||
try {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat(format, Locale.US);
|
||||
if (format.endsWith("Z")) {
|
||||
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
}
|
||||
Date date = sdf.parse(dateStr);
|
||||
if (date != null) return date.getTime();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
return System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
BIN
android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Web News</string>
|
||||
<string name="title_activity_main">Web News</string>
|
||||
<string name="package_name">com.quad4.webnews</string>
|
||||
<string name="custom_url_scheme">com.quad4.webnews</string>
|
||||
</resources>
|
||||
22
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
6
android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,6 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-sqlite'
|
||||
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
|
||||
22
android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
9
capacitor.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.quad4.webnews',
|
||||
appName: 'Web News',
|
||||
webDir: 'build'
|
||||
};
|
||||
|
||||
export default config;
|
||||
286
desktop/app.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Quad4-Software/webnews/internal/api"
|
||||
"git.quad4.io/Quad4-Software/webnews/internal/storage"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
am *api.AuthManager
|
||||
db *storage.SQLiteDB
|
||||
port int
|
||||
debug bool
|
||||
}
|
||||
|
||||
// NewApp creates a new App struct
|
||||
func NewApp(debug bool) *App {
|
||||
// Initialize SQLite in the user's home directory
|
||||
home, err := os.UserHomeDir()
|
||||
dbPath := "webnews.db"
|
||||
if err == nil {
|
||||
dbPath = filepath.Join(home, ".config", "webnews", "library.db")
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Printf("[debug] using database path: %s\n", dbPath)
|
||||
}
|
||||
|
||||
db, err := storage.NewSQLiteDB(dbPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to initialize SQLite, falling back to IndexedDB: %v\n", err)
|
||||
} else if debug {
|
||||
fmt.Printf("[debug] SQLite initialized\n")
|
||||
}
|
||||
|
||||
return &App{
|
||||
am: api.NewAuthManager("none", "", "", false),
|
||||
db: db,
|
||||
debug: debug,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) logDebug(format string, args ...any) {
|
||||
if a != nil && a.debug {
|
||||
fmt.Printf("[debug] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// logHandler wraps HTTP handlers to log requests when debug is enabled.
|
||||
func (a *App) logHandler(next http.Handler) http.Handler {
|
||||
if !a.debug {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
fmt.Printf("[debug] http %s %s %dms\n", r.Method, r.URL.Path, time.Since(start).Milliseconds())
|
||||
})
|
||||
}
|
||||
|
||||
// GetDBStats returns database statistics
|
||||
func (a *App) GetDBStats() (storage.DBStats, error) {
|
||||
a.logDebug("GetDBStats")
|
||||
if a.db == nil {
|
||||
return storage.DBStats{}, fmt.Errorf("SQLite not initialized")
|
||||
}
|
||||
return a.db.GetStats()
|
||||
}
|
||||
|
||||
// VacuumDB runs the VACUUM command
|
||||
func (a *App) VacuumDB() error {
|
||||
a.logDebug("VacuumDB")
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("SQLite not initialized")
|
||||
}
|
||||
return a.db.Vacuum()
|
||||
}
|
||||
|
||||
// CheckDBIntegrity runs a PRAGMA integrity_check
|
||||
func (a *App) CheckDBIntegrity() (string, error) {
|
||||
a.logDebug("CheckDBIntegrity")
|
||||
if a.db == nil {
|
||||
return "", fmt.Errorf("SQLite not initialized")
|
||||
}
|
||||
return a.db.IntegrityCheck()
|
||||
}
|
||||
|
||||
// Storage methods for Wails to call
|
||||
func (a *App) SaveSettings(settings string) error {
|
||||
a.logDebug("SaveSettings")
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.SaveSettings(settings)
|
||||
}
|
||||
|
||||
func (a *App) GetSettings() (string, error) {
|
||||
a.logDebug("GetSettings")
|
||||
if a.db == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
return a.db.GetSettings()
|
||||
}
|
||||
|
||||
func (a *App) SaveCategories(cats string) error {
|
||||
a.logDebug("SaveCategories")
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.SaveCategories(cats)
|
||||
}
|
||||
|
||||
func (a *App) GetCategories() (string, error) {
|
||||
a.logDebug("GetCategories")
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.GetCategories()
|
||||
}
|
||||
|
||||
func (a *App) SaveFeeds(feeds string) error {
|
||||
a.logDebug("SaveFeeds")
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.SaveFeeds(feeds)
|
||||
}
|
||||
|
||||
func (a *App) GetFeeds() (string, error) {
|
||||
a.logDebug("GetFeeds")
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.GetFeeds()
|
||||
}
|
||||
|
||||
func (a *App) SaveArticles(articles string) error {
|
||||
a.logDebug("SaveArticles")
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.SaveArticles(articles)
|
||||
}
|
||||
|
||||
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
|
||||
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.GetArticles(feedId, offset, limit)
|
||||
}
|
||||
|
||||
func (a *App) SearchArticles(query string, limit int) (string, error) {
|
||||
a.logDebug("SearchArticles query=%s limit=%d", query, limit)
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.SearchArticles(query, limit)
|
||||
}
|
||||
|
||||
func (a *App) UpdateArticle(article string) error {
|
||||
a.logDebug("UpdateArticle")
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.UpdateArticle(article)
|
||||
}
|
||||
|
||||
func (a *App) MarkAsRead(id string) error {
|
||||
a.logDebug("MarkAsRead id=%s", id)
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.MarkAsRead(id)
|
||||
}
|
||||
|
||||
func (a *App) DeleteFeed(feedId string) error {
|
||||
a.logDebug("DeleteFeed feedId=%s", feedId)
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.DeleteFeed(feedId)
|
||||
}
|
||||
|
||||
func (a *App) PurgeOldContent(days int) (int64, error) {
|
||||
a.logDebug("PurgeOldContent days=%d", days)
|
||||
if a.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return a.db.PurgeOldContent(days)
|
||||
}
|
||||
|
||||
func (a *App) ClearAll() error {
|
||||
a.logDebug("ClearAll")
|
||||
if a.db == nil {
|
||||
return nil
|
||||
}
|
||||
return a.db.ClearAll()
|
||||
}
|
||||
|
||||
func (a *App) GetReadingHistory(days int) (string, error) {
|
||||
a.logDebug("GetReadingHistory days=%d", days)
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.GetReadingHistory(days)
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
a.logDebug("startup begin")
|
||||
|
||||
// Start local API server on a random port
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Printf("Error starting local server: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.port = listener.Addr().(*net.TCPAddr).Port
|
||||
a.logDebug("local API listener bound on %s", listener.Addr().String())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// CORS middleware for local desktop API
|
||||
cors := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Account-Number")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Register handlers from our common api package
|
||||
mux.HandleFunc("/api/feed", cors(api.HandleFeedProxy))
|
||||
mux.HandleFunc("/api/proxy", cors(api.HandleProxy))
|
||||
mux.HandleFunc("/api/fulltext", cors(api.HandleFullText))
|
||||
mux.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","auth":{"required":false,"mode":"none","canReg":false}}`)
|
||||
}))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: listener.Addr().String(),
|
||||
Handler: a.logHandler(mux),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Printf("Error serving desktop API: %v\n", err)
|
||||
}
|
||||
}()
|
||||
fmt.Printf("Desktop API server started on port %d\n", a.port)
|
||||
a.logDebug("startup complete")
|
||||
}
|
||||
|
||||
// GetAPIPort returns the port the local server is running on
|
||||
// This can be used by the frontend to know where to send requests
|
||||
func (a *App) GetAPIPort() int {
|
||||
a.logDebug("GetAPIPort -> %d", a.port)
|
||||
return a.port
|
||||
}
|
||||
|
||||
// LogFrontend allows the frontend to log to the terminal
|
||||
func (a *App) LogFrontend(message string) {
|
||||
fmt.Printf("[frontend] %s\n", message)
|
||||
}
|
||||
55
desktop/go.mod
Normal file
@@ -0,0 +1,55 @@
|
||||
module git.quad4.io/Quad4-Software/webnews/desktop
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
git.quad4.io/Quad4-Software/webnews v0.0.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.quad4.io/Go-Libs/RSS v1.0.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.41.0 // indirect
|
||||
)
|
||||
|
||||
replace git.quad4.io/Quad4-Software/webnews => ../
|
||||
209
desktop/go.sum
Normal file
@@ -0,0 +1,209 @@
|
||||
git.quad4.io/Go-Libs/RSS v1.0.0 h1:iJ5wqCf+al932YdZUA8RQ5QVb9rtvlNaBMYQ9vcTMkE=
|
||||
git.quad4.io/Go-Libs/RSS v1.0.0/go.mod h1:M5k02wI4gNqy1tr/9Tz9H50/GaXl4fxVoipV7yASIP8=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
|
||||
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
52
desktop/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
//go:embed all:frontend_dist
|
||||
var assets embed.FS
|
||||
|
||||
func debugEnabled() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--debug" || arg == "-d" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
debug := debugEnabled()
|
||||
if debug {
|
||||
println("Debug logging enabled")
|
||||
}
|
||||
|
||||
// Create an instance of the app structure
|
||||
app := NewApp(debug)
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Web News",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
OnStartup: app.startup,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
EnableDefaultContextMenu: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
}
|
||||
}
|
||||
15
desktop/wails.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Web News",
|
||||
"assetdir": "frontend_dist",
|
||||
"frontend:dir": "..",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||
"outputfilename": "web-news",
|
||||
"author": {
|
||||
"name": "Quad4",
|
||||
"email": "dev@quad4.io"
|
||||
}
|
||||
}
|
||||
|
||||
135
eslint.config.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import js from '@eslint/js';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sveltePlugin from 'eslint-plugin-svelte';
|
||||
import svelteParser from 'svelte-eslint-parser';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,svelte}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
globals: {
|
||||
fetch: 'readonly',
|
||||
caches: 'readonly',
|
||||
URL: 'readonly',
|
||||
console: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLImageElement: 'readonly',
|
||||
HTMLInputElement: 'readonly',
|
||||
HTMLTextAreaElement: 'readonly',
|
||||
HTMLSelectElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
SVGSVGElement: 'readonly',
|
||||
navigator: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
Blob: 'readonly',
|
||||
Event: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
TouchEvent: 'readonly',
|
||||
Touch: 'readonly',
|
||||
WheelEvent: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
atob: 'readonly',
|
||||
btoa: 'readonly',
|
||||
alert: 'readonly',
|
||||
prompt: 'readonly',
|
||||
confirm: 'readonly',
|
||||
XMLSerializer: 'readonly',
|
||||
Image: 'readonly',
|
||||
FileReader: 'readonly',
|
||||
performance: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
DOMParser: 'readonly',
|
||||
Element: 'readonly',
|
||||
Node: 'readonly',
|
||||
DragEvent: 'readonly',
|
||||
ServiceWorkerRegistration: 'readonly',
|
||||
Response: 'readonly',
|
||||
IDBDatabase: 'readonly',
|
||||
IDBOpenDBRequest: 'readonly',
|
||||
IDBObjectStore: 'readonly',
|
||||
IDBKeyRange: 'readonly',
|
||||
IDBRequest: 'readonly',
|
||||
IDBCursor: 'readonly',
|
||||
IDBCursorWithValue: 'readonly',
|
||||
indexedDB: 'readonly',
|
||||
$state: 'readonly',
|
||||
$derived: 'readonly',
|
||||
$effect: 'readonly',
|
||||
$props: 'readonly',
|
||||
$inspect: 'readonly',
|
||||
$host: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
svelte: sveltePlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
svelte: sveltePlugin,
|
||||
},
|
||||
rules: {
|
||||
...sveltePlugin.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['bin/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
process: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['static/sw.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
self: 'readonly',
|
||||
caches: 'readonly',
|
||||
fetch: 'readonly',
|
||||
URL: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'.svelte-kit/**',
|
||||
'build/**',
|
||||
'dist/**',
|
||||
'archive/**',
|
||||
'desktop/frontend_dist/**',
|
||||
'android/**',
|
||||
'wailsjs/**',
|
||||
'bin/**',
|
||||
],
|
||||
},
|
||||
];
|
||||
31
go.mod
Normal file
@@ -0,0 +1,31 @@
|
||||
module git.quad4.io/Quad4-Software/webnews
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
git.quad4.io/Go-Libs/RSS v1.0.0
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
|
||||
golang.org/x/time v0.14.0
|
||||
modernc.org/sqlite v1.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
157
go.sum
Normal file
@@ -0,0 +1,157 @@
|
||||
git.quad4.io/Go-Libs/RSS v1.0.0 h1:iJ5wqCf+al932YdZUA8RQ5QVb9rtvlNaBMYQ9vcTMkE=
|
||||
git.quad4.io/Go-Libs/RSS v1.0.0/go.mod h1:M5k02wI4gNqy1tr/9Tz9H50/GaXl4fxVoipV7yASIP8=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
|
||||
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
512
internal/api/handlers.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Go-Libs/RSS"
|
||||
readability "github.com/go-shiori/go-readability"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type ProxyResponse struct {
|
||||
Feed FeedInfo `json:"feed"`
|
||||
Articles []Article `json:"articles"`
|
||||
}
|
||||
|
||||
type FeedInfo struct {
|
||||
Title string `json:"title"`
|
||||
SiteURL string `json:"siteUrl"`
|
||||
Description string `json:"description"`
|
||||
LastFetched int64 `json:"lastFetched"`
|
||||
}
|
||||
|
||||
type Article struct {
|
||||
ID string `json:"id"`
|
||||
FeedID string `json:"feedId"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
PubDate int64 `json:"pubDate"`
|
||||
Read bool `json:"read"`
|
||||
Saved bool `json:"saved"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
clients map[string]*rate.Limiter
|
||||
mu *sync.RWMutex
|
||||
r rate.Limit
|
||||
b int
|
||||
File string
|
||||
}
|
||||
|
||||
func NewRateLimiter(r rate.Limit, b int, file string) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
clients: make(map[string]*rate.Limiter),
|
||||
mu: &sync.RWMutex{},
|
||||
r: r,
|
||||
b: b,
|
||||
File: file,
|
||||
}
|
||||
if file != "" {
|
||||
rl.LoadHashes()
|
||||
}
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) LoadHashes() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(rl.File)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var hashes []string
|
||||
if err := json.Unmarshal(data, &hashes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, h := range hashes {
|
||||
rl.clients[h] = rate.NewLimiter(rl.r, rl.b)
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) SaveHashes() {
|
||||
rl.mu.RLock()
|
||||
var hashes []string
|
||||
for h := range rl.clients {
|
||||
hashes = append(hashes, h)
|
||||
}
|
||||
rl.mu.RUnlock()
|
||||
|
||||
data, _ := json.MarshalIndent(hashes, "", " ")
|
||||
os.WriteFile(rl.File, data, 0600)
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
|
||||
rl.mu.RLock()
|
||||
limiter, exists := rl.clients[id]
|
||||
rl.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
rl.mu.Lock()
|
||||
limiter = rate.NewLimiter(rl.r, rl.b)
|
||||
rl.clients[id] = limiter
|
||||
rl.mu.Unlock()
|
||||
if rl.File != "" {
|
||||
rl.SaveHashes()
|
||||
}
|
||||
}
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
var Limiter = NewRateLimiter(rate.Every(time.Second), 5, "")
|
||||
|
||||
var ForbiddenPatterns = []string{
|
||||
".git", ".env", ".aws", ".config", ".ssh",
|
||||
"wp-admin", "wp-login", "phpinfo", ".php",
|
||||
"etc/passwd", "cgi-bin",
|
||||
}
|
||||
|
||||
func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.ToLower(r.URL.Path)
|
||||
query := strings.ToLower(r.URL.RawQuery)
|
||||
|
||||
for _, pattern := range ForbiddenPatterns {
|
||||
if strings.Contains(path, pattern) || strings.Contains(query, pattern) {
|
||||
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type AuthManager struct {
|
||||
Mode string // "none", "token", "multi"
|
||||
MasterToken string
|
||||
AllowRegistration bool
|
||||
AuthFile string
|
||||
Tokens map[string]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewAuthManager(mode, token, file string, allowReg bool) *AuthManager {
|
||||
am := &AuthManager{
|
||||
Mode: mode,
|
||||
MasterToken: token,
|
||||
AllowRegistration: allowReg,
|
||||
AuthFile: file,
|
||||
Tokens: make(map[string]bool),
|
||||
}
|
||||
|
||||
if mode == "multi" && file != "" {
|
||||
am.LoadTokens()
|
||||
}
|
||||
|
||||
return am
|
||||
}
|
||||
|
||||
func (am *AuthManager) LoadTokens() {
|
||||
am.mu.Lock()
|
||||
defer am.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(am.AuthFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
log.Printf("Error reading auth file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var tokens []string
|
||||
if err := json.Unmarshal(data, &tokens); err != nil {
|
||||
log.Printf("Error parsing auth file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range tokens {
|
||||
am.Tokens[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthManager) SaveTokens() {
|
||||
am.mu.RLock()
|
||||
var tokens []string
|
||||
for t := range am.Tokens {
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
am.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(tokens, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling tokens: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(am.AuthFile, data, 0600); err != nil {
|
||||
log.Printf("Error writing auth file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthManager) Validate(token string) bool {
|
||||
if am.Mode == "none" {
|
||||
return true
|
||||
}
|
||||
if am.Mode == "token" {
|
||||
return token == am.MasterToken
|
||||
}
|
||||
if am.Mode == "multi" {
|
||||
am.mu.RLock()
|
||||
defer am.mu.RUnlock()
|
||||
return am.Tokens[token]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (am *AuthManager) Register() (string, error) {
|
||||
if am.Mode != "multi" || !am.AllowRegistration {
|
||||
return "", http.ErrNotSupported
|
||||
}
|
||||
|
||||
b := make([]byte, 8)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
formatted := token[0:4] + "-" + token[4:8] + "-" + token[8:12] + "-" + token[12:16]
|
||||
|
||||
am.mu.Lock()
|
||||
am.Tokens[formatted] = true
|
||||
am.mu.Unlock()
|
||||
|
||||
am.SaveTokens()
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if am.Mode == "none" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := r.Header.Get("X-Account-Number")
|
||||
if token == "" {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
if !am.Validate(token) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="Web News"`)
|
||||
http.Error(w, "Unauthorized: Invalid Account Number", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
ip = xff[:comma]
|
||||
} else {
|
||||
ip = xff
|
||||
}
|
||||
}
|
||||
|
||||
ua := r.Header.Get("User-Agent")
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(ip + ua))
|
||||
clientID := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
l := Limiter.GetLimiter(clientID)
|
||||
if !l.Allow() {
|
||||
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
feedURL := r.URL.Query().Get("url")
|
||||
if feedURL == "" {
|
||||
http.Error(w, "Missing url parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", feedURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
|
||||
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", "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 {
|
||||
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()
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
response := ProxyResponse{
|
||||
Feed: FeedInfo{
|
||||
Title: parsedFeed.Title,
|
||||
SiteURL: parsedFeed.Link,
|
||||
Description: parsedFeed.Description,
|
||||
LastFetched: time.Now().UnixMilli(),
|
||||
},
|
||||
Articles: articles,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("Error encoding feed proxy response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("url")
|
||||
if targetURL == "" {
|
||||
http.Error(w, "Missing url parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Add browser-like headers
|
||||
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 URL: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "Target returned status "+resp.Status, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
log.Printf("Error copying proxy response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type FullTextResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
TextContent string `json:"textContent"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Byline string `json:"byline"`
|
||||
SiteName string `json:"siteName"`
|
||||
Image string `json:"image"`
|
||||
Favicon string `json:"favicon"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func HandleFullText(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("url")
|
||||
if targetURL == "" {
|
||||
http.Error(w, "Missing url parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
parsedURL, _ := url.Parse(targetURL)
|
||||
article, err := readability.FromURL(targetURL, 15*time.Second)
|
||||
if err != nil {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("Error encoding fulltext response: %v", err)
|
||||
}
|
||||
}
|
||||
512
internal/storage/sqlite.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type SQLiteDB struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewSQLiteDB(path string) (*SQLiteDB, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &SQLiteDB{db: db}
|
||||
if err := s.init(); err != nil {
|
||||
if closeErr := db.Close(); closeErr != nil {
|
||||
return nil, fmt.Errorf("init error: %v, close error: %v", err, closeErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) init() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Enable WAL mode
|
||||
_, err := s.db.Exec("PRAGMA journal_mode=WAL;")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
"order" INTEGER
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
categoryId TEXT,
|
||||
"order" INTEGER,
|
||||
enabled INTEGER,
|
||||
fetchInterval INTEGER,
|
||||
FOREIGN KEY(categoryId) REFERENCES categories(id)
|
||||
);`,
|
||||
`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,
|
||||
FOREIGN KEY(feedId) REFERENCES feeds(id)
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Database Stats and Operations
|
||||
|
||||
type DBStats struct {
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
Articles int `json:"articles"`
|
||||
Feeds int `json:"feeds"`
|
||||
WALEnabled bool `json:"walEnabled"`
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetStats() (DBStats, error) {
|
||||
var stats DBStats
|
||||
|
||||
// Get file size
|
||||
var path string
|
||||
err := s.db.QueryRow("PRAGMA database_list").Scan(interface{}(nil), interface{}(nil), &path)
|
||||
if err != nil {
|
||||
// Fallback if PRAGMA fails
|
||||
return stats, err
|
||||
}
|
||||
stats.Path = path
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err == nil {
|
||||
stats.Size = fi.Size()
|
||||
}
|
||||
|
||||
// Count articles
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM articles").Scan(&stats.Articles); err != nil {
|
||||
log.Printf("Error counting articles: %v", err)
|
||||
}
|
||||
|
||||
// Count feeds
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&stats.Feeds); err != nil {
|
||||
log.Printf("Error counting feeds: %v", err)
|
||||
}
|
||||
|
||||
// Check WAL
|
||||
var mode string
|
||||
if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
|
||||
log.Printf("Error checking journal mode: %v", err)
|
||||
}
|
||||
stats.WALEnabled = mode == "wal"
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Vacuum() error {
|
||||
_, err := s.db.Exec("VACUUM")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) IntegrityCheck() (string, error) {
|
||||
var res string
|
||||
err := s.db.QueryRow("PRAGMA integrity_check").Scan(&res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Data Operations
|
||||
|
||||
func (s *SQLiteDB) SaveSettings(settingsJSON string) error {
|
||||
_, err := s.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", settingsJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetSettings() (string, error) {
|
||||
var val string
|
||||
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'main'").Scan(&val)
|
||||
if err == sql.ErrNoRows {
|
||||
return "{}", nil
|
||||
}
|
||||
return val, err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) SaveCategories(catsJSON string) error {
|
||||
var cats []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(catsJSON), &cats); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, c := range cats {
|
||||
_, err := tx.Exec("INSERT OR REPLACE INTO categories (id, name, \"order\") VALUES (?, ?, ?)", c.ID, c.Name, c.Order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetCategories() (string, error) {
|
||||
rows, err := s.db.Query("SELECT id, name, \"order\" FROM categories ORDER BY \"order\" ASC")
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cats []map[string]any = []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
var order int
|
||||
if err := rows.Scan(&id, &name, &order); err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
cats = append(cats, map[string]any{"id": id, "name": name, "order": order})
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(cats)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) SaveFeeds(feedsJSON string) error {
|
||||
var feeds []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CategoryID string `json:"categoryId"`
|
||||
Order int `json:"order"`
|
||||
Enabled bool `json:"enabled"`
|
||||
FetchInterval int `json:"fetchInterval"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(feedsJSON), &feeds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, f := range feeds {
|
||||
enabled := 0
|
||||
if f.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := tx.Exec("INSERT OR REPLACE INTO feeds (id, title, categoryId, \"order\", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
f.ID, f.Title, f.CategoryID, f.Order, enabled, f.FetchInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetFeeds() (string, error) {
|
||||
rows, err := s.db.Query("SELECT id, title, categoryId, \"order\", enabled, fetchInterval FROM feeds ORDER BY \"order\" ASC")
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var feeds []map[string]any = []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, title, categoryId string
|
||||
var order, enabled, fetchInterval int
|
||||
if err := rows.Scan(&id, &title, &categoryId, &order, &enabled, &fetchInterval); err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
feeds = append(feeds, map[string]any{
|
||||
"id": id,
|
||||
"title": title,
|
||||
"categoryId": categoryId,
|
||||
"order": order,
|
||||
"enabled": enabled == 1,
|
||||
"fetchInterval": fetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(feeds)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
|
||||
var articles []map[string]any
|
||||
if err := json.Unmarshal([]byte(articlesJSON), &articles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, a := range articles {
|
||||
read := 0
|
||||
if r, ok := a["read"].(bool); ok && r {
|
||||
read = 1
|
||||
}
|
||||
saved := 0
|
||||
if sa, ok := a["saved"].(bool); ok && sa {
|
||||
saved = 1
|
||||
}
|
||||
|
||||
_, err := tx.Exec(`INSERT OR REPLACE INTO articles
|
||||
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a["id"], a["feedId"], a["title"], a["link"], a["description"], a["content"], a["author"], a["pubDate"], read, saved, a["imageUrl"], a["readAt"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var articles []map[string]any = []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, feedId, title, link, description, content, author, imageUrl sql.NullString
|
||||
var pubDate int64
|
||||
var read, saved int
|
||||
var readAt sql.NullInt64
|
||||
|
||||
err := rows.Scan(&id, &feedId, &title, &link, &description, &content, &author, &pubDate, &read, &saved, &imageUrl, &readAt)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
a := map[string]any{
|
||||
"id": id.String,
|
||||
"feedId": feedId.String,
|
||||
"title": title.String,
|
||||
"link": link.String,
|
||||
"description": description.String,
|
||||
"content": content.String,
|
||||
"author": author.String,
|
||||
"pubDate": pubDate,
|
||||
"read": read == 1,
|
||||
"saved": saved == 1,
|
||||
"imageUrl": imageUrl.String,
|
||||
}
|
||||
if readAt.Valid {
|
||||
a["readAt"] = readAt.Int64
|
||||
}
|
||||
articles = append(articles, a)
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(articles)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
|
||||
q := "%" + query + "%"
|
||||
rows, err := s.db.Query(`SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt
|
||||
FROM articles
|
||||
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
|
||||
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var articles []map[string]any = []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, feedId, title, link, description, content, author, imageUrl sql.NullString
|
||||
var pubDate int64
|
||||
var read, saved int
|
||||
var readAt sql.NullInt64
|
||||
|
||||
err := rows.Scan(&id, &feedId, &title, &link, &description, &content, &author, &pubDate, &read, &saved, &imageUrl, &readAt)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
a := map[string]any{
|
||||
"id": id.String,
|
||||
"feedId": feedId.String,
|
||||
"title": title.String,
|
||||
"link": link.String,
|
||||
"description": description.String,
|
||||
"content": content.String,
|
||||
"author": author.String,
|
||||
"pubDate": pubDate,
|
||||
"read": read == 1,
|
||||
"saved": saved == 1,
|
||||
"imageUrl": imageUrl.String,
|
||||
}
|
||||
if readAt.Valid {
|
||||
a["readAt"] = readAt.Int64
|
||||
}
|
||||
articles = append(articles, a)
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(articles)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateArticle(articleJSON string) error {
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal([]byte(articleJSON), &a); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
read := 0
|
||||
if r, ok := a["read"].(bool); ok && r {
|
||||
read = 1
|
||||
}
|
||||
saved := 0
|
||||
if sa, ok := a["saved"].(bool); ok && sa {
|
||||
saved = 1
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`UPDATE articles SET read = ?, saved = ?, readAt = ?, content = ? WHERE id = ?`,
|
||||
read, saved, a["readAt"], a["content"], a["id"])
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) MarkAsRead(id string) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.db.Exec(`UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0`, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteFeed(feedId string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM articles WHERE feedId = ?", feedId); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM feeds WHERE id = ?", feedId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) PurgeOldContent(days int) (int64, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
|
||||
res, err := s.db.Exec("UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?", cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ClearAll() error {
|
||||
queries := []string{
|
||||
"DELETE FROM articles",
|
||||
"DELETE FROM feeds",
|
||||
"DELETE FROM categories",
|
||||
"DELETE FROM settings",
|
||||
}
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
|
||||
FROM articles
|
||||
WHERE read = 1 AND readAt > ?
|
||||
GROUP BY date
|
||||
ORDER BY date DESC`, cutoff)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []map[string]any = []map[string]any{}
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var count int
|
||||
if err := rows.Scan(&date, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
// Convert date string back to timestamp for frontend
|
||||
t, _ := time.Parse("2006-01-02", date)
|
||||
history = append(history, map[string]any{
|
||||
"date": t.UnixMilli(),
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(history)
|
||||
return string(b), nil
|
||||
}
|
||||
220
main.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Quad4-Software/webnews/internal/api"
|
||||
)
|
||||
|
||||
//go:embed build/*
|
||||
var buildAssets embed.FS
|
||||
|
||||
func corsMiddleware(allowedOrigins []string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
allowed := false
|
||||
if len(allowedOrigins) == 0 {
|
||||
allowed = true
|
||||
} else {
|
||||
for _, o := range allowedOrigins {
|
||||
if o == "*" || o == origin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allowed {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
if allowed {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed && len(allowedOrigins) > 0 {
|
||||
log.Printf("Blocked CORS request from origin: %s", origin)
|
||||
http.Error(w, "CORS Origin Not Allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
frontendPath := flag.String("frontend", "", "Path to custom frontend build directory (overrides embedded assets)")
|
||||
host := flag.String("host", "0.0.0.0", "Host to bind the server to")
|
||||
port := flag.String("port", "", "Port to listen on (overrides PORT env var)")
|
||||
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
|
||||
|
||||
// Auth flags
|
||||
authMode := flag.String("auth-mode", "none", "Authentication mode: none, token, multi")
|
||||
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")
|
||||
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting")
|
||||
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *hashesFile != "" {
|
||||
api.Limiter.File = *hashesFile
|
||||
api.Limiter.LoadHashes()
|
||||
}
|
||||
|
||||
am := api.NewAuthManager(*authMode, *authToken, *authFile, *allowReg)
|
||||
|
||||
var allowedOrigins []string
|
||||
if *allowedOriginsStr != "" {
|
||||
origins := strings.Split(*allowedOriginsStr, ",")
|
||||
for _, o := range origins {
|
||||
allowedOrigins = append(allowedOrigins, strings.TrimSpace(o))
|
||||
}
|
||||
}
|
||||
|
||||
if *port == "" {
|
||||
*port = os.Getenv("PORT")
|
||||
if *port == "" {
|
||||
*port = "8080"
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware chains
|
||||
cors := corsMiddleware(allowedOrigins)
|
||||
auth := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
return api.AuthMiddleware(am, h)
|
||||
}
|
||||
|
||||
// Setup handlers with optional protection
|
||||
bot := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
if *disableProtection {
|
||||
return h
|
||||
}
|
||||
return api.BotBlockerMiddleware(h)
|
||||
}
|
||||
|
||||
limit := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
if *disableProtection {
|
||||
return h
|
||||
}
|
||||
return api.LimitMiddleware(h)
|
||||
}
|
||||
|
||||
apiHandler := cors(auth(bot(limit(api.HandleFeedProxy))))
|
||||
proxyHandler := cors(auth(bot(limit(api.HandleProxy))))
|
||||
fullTextHandler := cors(auth(bot(limit(api.HandleFullText))))
|
||||
|
||||
pingHandler := cors(bot(limit(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Include auth info in ping if no specific origin check is needed
|
||||
authRequired := am.Mode != "none"
|
||||
canRegister := am.Mode == "multi" && am.AllowRegistration
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"auth": map[string]any{
|
||||
"required": authRequired,
|
||||
"mode": am.Mode,
|
||||
"canReg": canRegister,
|
||||
},
|
||||
}); err != nil {
|
||||
log.Printf("Error encoding ping response: %v", err)
|
||||
}
|
||||
})))
|
||||
|
||||
// Auth Routes
|
||||
http.HandleFunc("/api/auth/register", cors(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token, err := am.Register()
|
||||
if err != nil {
|
||||
http.Error(w, "Registration disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"accountNumber": token}); err != nil {
|
||||
log.Printf("Error encoding registration response: %v", err)
|
||||
}
|
||||
}))
|
||||
|
||||
http.HandleFunc("/api/auth/verify", cors(auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"valid": true}); err != nil {
|
||||
log.Printf("Error encoding verification response: %v", err)
|
||||
}
|
||||
})))
|
||||
|
||||
http.HandleFunc("/api/feed", apiHandler)
|
||||
http.HandleFunc("/api/proxy", proxyHandler)
|
||||
http.HandleFunc("/api/fulltext", fullTextHandler)
|
||||
http.HandleFunc("/api/ping", pingHandler)
|
||||
|
||||
// Static Assets
|
||||
var staticFS fs.FS
|
||||
if *frontendPath != "" {
|
||||
log.Printf("Using custom frontend from: %s\n", *frontendPath)
|
||||
staticFS = os.DirFS(*frontendPath)
|
||||
} else {
|
||||
sub, err := fs.Sub(buildAssets, "build")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
staticFS = sub
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
|
||||
// SPA Handler
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
|
||||
_, err := staticFS.Open(path)
|
||||
if err != nil {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
addr := net.JoinHostPort(*host, *port)
|
||||
log.Printf("Web News server starting on %s...\n", addr)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: nil,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
5462
package-lock.json
generated
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "web-news",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
"bin": {
|
||||
"web-news": "./bin/web-news.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"files": [
|
||||
"build/**/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
|
||||
"dev:go": "go run main.go",
|
||||
"prebuild": "node scripts/inject-sw-version.js",
|
||||
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
|
||||
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
|
||||
"start": "./web-news",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"package": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor-community/sqlite": "^7.0.2",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
"@typescript-eslint/parser": "^8.50.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-eslint-parser": "^1.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
6
scripts/build.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building app..."
|
||||
VITE_APP_VERSION=$(node -p "require('./package.json').version") npm run build
|
||||
|
||||
9
scripts/check.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Running Svelte sync..."
|
||||
npx svelte-kit sync
|
||||
|
||||
echo "Running svelte-check (fail on errors)..."
|
||||
npx svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||
24
scripts/inject-sw-version.js
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'));
|
||||
const version = packageJson.version;
|
||||
|
||||
const swPath = join(rootDir, 'static', 'sw.js');
|
||||
let swContent = readFileSync(swPath, 'utf-8');
|
||||
|
||||
swContent = swContent.replace(
|
||||
/const CACHE_VERSION = ['"](.*?)['"];/,
|
||||
`const CACHE_VERSION = '${version}';`
|
||||
);
|
||||
|
||||
writeFileSync(swPath, swContent);
|
||||
console.log(`Injected version ${version} into service worker`);
|
||||
|
||||
42
scripts/osv_scan.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OSV_VERSION="${OSV_VERSION:-v2.3.1}"
|
||||
|
||||
echo "Installing OSV-Scanner ${OSV_VERSION}..."
|
||||
curl -sSL "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64" -o /tmp/osv-scanner
|
||||
chmod +x /tmp/osv-scanner
|
||||
sudo mv /tmp/osv-scanner /usr/local/bin/osv-scanner
|
||||
|
||||
echo "Running OSV-Scanner recursively..."
|
||||
OSV_JSON="$(mktemp)"
|
||||
trap 'rm -f "$OSV_JSON"' EXIT
|
||||
|
||||
osv-scanner --recursive ./ --format json > "$OSV_JSON" || true
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "Error: jq is not installed. Please install jq to parse OSV results."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VULNS=$(jq -r '
|
||||
.results[]? |
|
||||
.source as $src |
|
||||
.vulns[]? |
|
||||
select(
|
||||
(.database_specific.severity // "" | ascii_upcase | test("HIGH|CRITICAL")) or
|
||||
(.severity[]?.score // "" | tostring | split("/")[0] | tonumber? // 0 | . >= 7.0)
|
||||
) |
|
||||
"\(.id) (source: \($src))"
|
||||
' "$OSV_JSON")
|
||||
|
||||
if [ -n "$VULNS" ]; then
|
||||
echo "OSV scan found HIGH/CRITICAL vulnerabilities:"
|
||||
echo "$VULNS" | while IFS= read -r line; do
|
||||
echo " - $line"
|
||||
done
|
||||
exit 1
|
||||
else
|
||||
echo "OSV scan: no HIGH/CRITICAL vulnerabilities found."
|
||||
fi
|
||||
|
||||
BIN
showcase/linkingtool.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
65
src/app.css
Normal file
@@ -0,0 +1,65 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--text-primary: #202124;
|
||||
--text-secondary: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--accent-blue: #1a73e8;
|
||||
--accent-blue-dark: #174ea6;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #121212;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #1f1f1f;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-blue-dark: #2563eb;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply bg-bg-primary text-text-primary overflow-x-hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 p-0 min-h-screen bg-bg-primary text-text-primary font-sans antialiased transition-colors duration-200 overflow-x-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
.mt-safe {
|
||||
margin-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
.mb-safe {
|
||||
margin-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-bg-primary border border-border-color rounded-lg overflow-hidden transition-all hover:shadow-md;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-accent-blue text-white px-4 py-2 rounded-md font-medium hover:bg-accent-blue-dark transition-colors;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
}
|
||||
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
18
src/app.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#1a73e8" />
|
||||
<meta name="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-title" content="Web News" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
102
src/components/AddFeedModal.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { db } from '$lib/db';
|
||||
import { fetchFeed } from '$lib/rss';
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import { X, Loader2 } from 'lucide-svelte';
|
||||
|
||||
let { onOpenChange } = $props();
|
||||
let feedUrl = $state('');
|
||||
let categoryId = $state(newsStore.categories[0]?.id || 'uncategorized');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!feedUrl) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const { feed, articles } = await fetchFeed(feedUrl);
|
||||
await db.saveFeed({
|
||||
id: feedUrl,
|
||||
title: feed.title || feedUrl,
|
||||
siteUrl: feed.siteUrl || '',
|
||||
description: feed.description || '',
|
||||
categoryId: categoryId,
|
||||
order: newsStore.feeds.length,
|
||||
lastFetched: Date.now(),
|
||||
fetchInterval: 30,
|
||||
enabled: true,
|
||||
consecutiveErrors: 0
|
||||
});
|
||||
await db.saveArticles(articles);
|
||||
await newsStore.refresh();
|
||||
onOpenChange(false);
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to add feed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<button
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
|
||||
onclick={() => onOpenChange(false)}
|
||||
aria-label="Close modal"
|
||||
></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="p-6 border-b border-border-color flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">Add RSS Feed</h2>
|
||||
<button class="text-text-secondary hover:text-text-primary" onclick={() => onOpenChange(false)}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="p-6 space-y-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div class="space-y-2">
|
||||
<label for="url" class="text-sm font-medium text-text-secondary">Feed URL</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
bind:value={feedUrl}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="category" class="text-sm font-medium text-text-secondary">Category</label>
|
||||
<select
|
||||
id="category"
|
||||
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"
|
||||
>
|
||||
{#each newsStore.categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-500 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary flex items-center justify-center gap-2 py-3"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 size={20} class="animate-spin" />
|
||||
Adding...
|
||||
{:else}
|
||||
Add Feed
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
168
src/components/ArticleCard.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import type { Article } from '$lib/db';
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
|
||||
|
||||
let { article }: { article: Article } = $props();
|
||||
let copied = $state(false);
|
||||
let loadingFullText = $state(false);
|
||||
|
||||
function formatDate(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getSource(feedId: string) {
|
||||
const feed = newsStore.feeds.find(f => f.id === feedId);
|
||||
return feed?.title || new URL(feedId).hostname;
|
||||
}
|
||||
|
||||
async function shareArticle(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const encodedUrl = btoa(article.link);
|
||||
const shareUrl = `${window.location.origin}/share?url=${encodedUrl}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
copied = true;
|
||||
toast.success('Share link copied to clipboard');
|
||||
setTimeout(() => copied = false, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy share link:', err);
|
||||
toast.error('Failed to copy share link');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSave(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const isSaved = await newsStore.toggleSave(article.id);
|
||||
if (isSaved) {
|
||||
newsStore.trackInteraction(article.id, 'save');
|
||||
toast.success('Added to saved stories');
|
||||
} else {
|
||||
toast.info('Removed from saved stories');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFullText(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
loadingFullText = true;
|
||||
newsStore.trackInteraction(article.id, 'click');
|
||||
const result = await newsStore.fetchFullText(article.link, article.id);
|
||||
if (result) {
|
||||
newsStore.readingArticle = result;
|
||||
}
|
||||
loadingFullText = false;
|
||||
}
|
||||
|
||||
function handleToggleSelect(e: Event) {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
if (checked) {
|
||||
newsStore.selectedArticleIds.add(article.id);
|
||||
} else {
|
||||
newsStore.selectedArticleIds.delete(article.id);
|
||||
}
|
||||
}
|
||||
</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' : ''}">
|
||||
{#if newsStore.isSelectMode}
|
||||
<div class="flex items-center pl-4 z-20">
|
||||
<div class="relative w-5 h-5">
|
||||
<input
|
||||
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"
|
||||
checked={newsStore.selectedArticleIds.has(article.id)}
|
||||
onchange={handleToggleSelect}
|
||||
/>
|
||||
<Check
|
||||
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"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
|
||||
onclick={() => {
|
||||
if (newsStore.isSelectMode) {
|
||||
const isSelected = newsStore.selectedArticleIds.has(article.id);
|
||||
if (isSelected) newsStore.selectedArticleIds.delete(article.id);
|
||||
else newsStore.selectedArticleIds.add(article.id);
|
||||
} else {
|
||||
newsStore.readingArticle = null;
|
||||
fetchFullText(new MouseEvent('click'));
|
||||
}
|
||||
}}
|
||||
aria-label="Open article: {article.title}"
|
||||
></button>
|
||||
|
||||
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
|
||||
<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>
|
||||
<span class="text-text-secondary text-xs">•</span>
|
||||
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold leading-snug mb-2 group-hover:text-accent-blue transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p class="text-text-secondary text-sm line-clamp-2 mb-4">
|
||||
{article.description}
|
||||
</p>
|
||||
|
||||
<div 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"
|
||||
onclick={fetchFullText}
|
||||
disabled={loadingFullText}
|
||||
>
|
||||
{#if loadingFullText}
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
{:else}
|
||||
<FileText size={16} />
|
||||
{/if}
|
||||
Read
|
||||
</button>
|
||||
<button
|
||||
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'}
|
||||
onclick={toggleSave}
|
||||
>
|
||||
<Bookmark size={18} fill={article.saved ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
onclick={shareArticle}
|
||||
>
|
||||
{#if copied}
|
||||
<Check size={18} class="text-green-500" />
|
||||
{:else}
|
||||
<Share2 size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
|
||||
title="Open in new tab"
|
||||
onclick={(e) => { e.stopPropagation(); window.open(article.link, '_blank'); }}
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</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>
|
||||
86
src/components/Navbar.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import { Sun, Moon, Search, Plus, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
let { onAddFeed } = $props();
|
||||
</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">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
|
||||
aria-label="Menu"
|
||||
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>
|
||||
</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"
|
||||
onclick={() => {
|
||||
newsStore.selectFeed(null);
|
||||
newsStore.currentView = 'all';
|
||||
newsStore.readingArticle = null;
|
||||
}}
|
||||
aria-label="Web News Home"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold tracking-tight hidden sm:block">Web News</h1>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 max-w-2xl mx-4 hidden sm:block">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
bind:value={newsStore.searchQuery}
|
||||
oninput={() => newsStore.loadArticles()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
{#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 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>
|
||||
</div>
|
||||
{: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="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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
|
||||
onclick={() => newsStore.refresh()}
|
||||
title="Refresh feeds"
|
||||
>
|
||||
<RefreshCw size={20} class={newsStore.loading ? 'animate-spin text-accent-blue' : ''} />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
|
||||
onclick={onAddFeed}
|
||||
title="Add RSS Feed"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
|
||||
onclick={() => newsStore.toggleTheme()}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{#if newsStore.settings.theme === 'dark'}
|
||||
<Sun size={20} />
|
||||
{:else}
|
||||
<Moon size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
416
src/components/Sidebar.svelte
Normal file
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import { db } from '$lib/db';
|
||||
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 { toast } from '$lib/toast.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let { onOpenSettings } = $props();
|
||||
|
||||
let expandedCategories = $state<Record<string, boolean>>({});
|
||||
|
||||
$effect(() => {
|
||||
// Expand new categories by default
|
||||
newsStore.categories.forEach(cat => {
|
||||
if (expandedCategories[cat.id] === undefined) {
|
||||
expandedCategories[cat.id] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let isManageMode = $state(false);
|
||||
let editingCategoryId = $state<string | null>(null);
|
||||
let editingCategoryName = $state('');
|
||||
let editingFeedId = $state<string | null>(null);
|
||||
let editingFeedTitle = $state('');
|
||||
let editingFeedUrl = $state('');
|
||||
let newCategoryName = $state('');
|
||||
|
||||
// Drag and drop state
|
||||
let draggedCategoryId = $state<string | null>(null);
|
||||
let draggedFeedId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
|
||||
function toggleCategory(id: string) {
|
||||
expandedCategories[id] = !expandedCategories[id];
|
||||
}
|
||||
|
||||
function getFeedsForCategory(categoryId: string) {
|
||||
return newsStore.feeds
|
||||
.filter(f => f.categoryId === categoryId)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, type: 'category' | 'feed', id: string) {
|
||||
if (type === 'category') {
|
||||
draggedCategoryId = id;
|
||||
draggedFeedId = null;
|
||||
} else {
|
||||
draggedFeedId = id;
|
||||
draggedCategoryId = null;
|
||||
}
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, id: string) {
|
||||
e.preventDefault();
|
||||
dragOverId = id;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent, targetType: 'category' | 'feed', targetId: string) {
|
||||
e.preventDefault();
|
||||
dragOverId = null;
|
||||
|
||||
if (draggedCategoryId && targetType === 'category') {
|
||||
const cats = [...newsStore.categories].sort((a, b) => a.order - b.order);
|
||||
const fromIndex = cats.findIndex(c => c.id === draggedCategoryId);
|
||||
const toIndex = cats.findIndex(c => c.id === targetId);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
const [moved] = cats.splice(fromIndex, 1);
|
||||
cats.splice(toIndex, 0, moved);
|
||||
await newsStore.reorderCategories(cats.map(c => c.id));
|
||||
}
|
||||
} else if (draggedFeedId && targetType === 'feed') {
|
||||
const sourceFeed = newsStore.feeds.find(f => f.id === draggedFeedId);
|
||||
const targetFeed = newsStore.feeds.find(f => f.id === targetId);
|
||||
|
||||
if (sourceFeed && targetFeed && sourceFeed.categoryId === targetFeed.categoryId) {
|
||||
const catFeeds = newsStore.feeds
|
||||
.filter(f => f.categoryId === sourceFeed.categoryId)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const fromIndex = catFeeds.findIndex(f => f.id === draggedFeedId);
|
||||
const toIndex = catFeeds.findIndex(f => f.id === targetId);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
const [moved] = catFeeds.splice(fromIndex, 1);
|
||||
catFeeds.splice(toIndex, 0, moved);
|
||||
await newsStore.reorderFeeds(catFeeds.map(f => f.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draggedCategoryId = null;
|
||||
draggedFeedId = null;
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
if (!newCategoryName) return;
|
||||
await newsStore.addCategory(newCategoryName);
|
||||
newCategoryName = '';
|
||||
}
|
||||
|
||||
function startEditCategory(cat: any) {
|
||||
editingCategoryId = cat.id;
|
||||
editingCategoryName = cat.name;
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!editingCategoryId) return;
|
||||
const cat = newsStore.categories.find(c => c.id === editingCategoryId);
|
||||
if (cat) {
|
||||
await newsStore.updateCategory({ ...cat, name: editingCategoryName });
|
||||
}
|
||||
editingCategoryId = null;
|
||||
}
|
||||
|
||||
function startEditFeed(feed: any) {
|
||||
editingFeedId = feed.id;
|
||||
editingFeedTitle = feed.title;
|
||||
editingFeedUrl = feed.id; // Currently ID is the URL
|
||||
}
|
||||
|
||||
async function saveFeed() {
|
||||
if (!editingFeedId) return;
|
||||
const feed = newsStore.feeds.find(f => f.id === editingFeedId);
|
||||
if (feed) {
|
||||
await newsStore.updateFeed({
|
||||
...feed,
|
||||
title: editingFeedTitle,
|
||||
id: editingFeedUrl
|
||||
}, editingFeedId);
|
||||
}
|
||||
editingFeedId = null;
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err);
|
||||
toast.error('Failed to import OPML');
|
||||
} finally {
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const opml = exportToOPML(newsStore.feeds, newsStore.categories);
|
||||
const blob = new Blob([opml], { type: 'text/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'feeds.opml';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('OPML exported');
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
toast.error('Failed to export OPML');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<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'}"
|
||||
onclick={() => { newsStore.selectView('all'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
|
||||
>
|
||||
<Home size={20} />
|
||||
<span>Top stories</span>
|
||||
</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 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}
|
||||
<X size={14} />
|
||||
{:else}
|
||||
<Edit2 size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isManageMode}
|
||||
<div class="px-4 mb-4 space-y-3">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCategoryName}
|
||||
placeholder="Add category..."
|
||||
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"
|
||||
onkeydown={(e) => e.key === 'Enter' && addCategory()}
|
||||
/>
|
||||
<button class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors" onclick={addCategory}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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">
|
||||
<Upload size={12} />
|
||||
Import
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1" role="list">
|
||||
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
|
||||
{@const catFeeds = getFeedsForCategory(cat.id)}
|
||||
{#if catFeeds.length > 0 || isManageMode}
|
||||
<div
|
||||
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id ? '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}
|
||||
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingCategoryId === cat.id}
|
||||
<div class="flex-1 flex items-center gap-1 px-2 py-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingCategoryName}
|
||||
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
|
||||
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
|
||||
/>
|
||||
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
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"
|
||||
onclick={() => toggleCategory(cat.id)}
|
||||
title={cat.name}
|
||||
>
|
||||
{#if expandedCategories[cat.id]}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
<span class="truncate">{cat.name}</span>
|
||||
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full">{catFeeds.length}</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}
|
||||
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
|
||||
<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}
|
||||
<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 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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center pb-24 md:pb-6 space-y-4">
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<a
|
||||
href="https://git.quad4.io/Quad4-Software/webnews"
|
||||
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} />
|
||||
<span>v0.1.0</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>
|
||||
|
||||
<button
|
||||
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]"
|
||||
onclick={onOpenSettings}
|
||||
>
|
||||
<SettingsIcon size={18} />
|
||||
<span class="font-medium text-sm">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
39
src/components/Toasts.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import { X, Info, CheckCircle, AlertCircle, AlertTriangle } from 'lucide-svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
const icons = {
|
||||
info: Info,
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle
|
||||
};
|
||||
|
||||
const colors = {
|
||||
info: 'text-blue-500 bg-bg-secondary/95 border-blue-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',
|
||||
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30'
|
||||
};
|
||||
</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">
|
||||
{#each toast.toasts as t (t.id)}
|
||||
<div
|
||||
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 }}
|
||||
out:fade={{ duration: 200 }}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="text-text-secondary hover:text-text-primary transition-colors p-1"
|
||||
onclick={() => toast.remove(t.id)}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
5
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +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">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/>
|
||||
<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="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 374 B |
797
src/lib/db.ts
Normal file
@@ -0,0 +1,797 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { SQLiteConnection, type SQLiteDBConnection, CapacitorSQLite } from '@capacitor-community/sqlite';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string; // URL as ID
|
||||
title: string;
|
||||
siteUrl: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
categoryId: string;
|
||||
lastFetched: number;
|
||||
fetchInterval: number; // in minutes
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
error?: string;
|
||||
consecutiveErrors: number;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string; // GUID or URL
|
||||
feedId: string;
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
content?: string;
|
||||
author?: string;
|
||||
pubDate: number;
|
||||
read: boolean;
|
||||
saved: boolean;
|
||||
imageUrl?: string;
|
||||
readAt?: number;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
globalFetchInterval: number; // in minutes
|
||||
autoFetch: boolean;
|
||||
apiBaseUrl: string;
|
||||
smartFeed: boolean;
|
||||
readingMode: 'inline' | 'pane';
|
||||
paneWidth: number; // percentage
|
||||
fontFamily: 'sans' | 'serif';
|
||||
fontSize: number; // in pixels
|
||||
lineHeight: number; // multiplier
|
||||
contentPurgeDays: number;
|
||||
authToken: string | null;
|
||||
muteFilters: string[];
|
||||
shortcuts: {
|
||||
next: string;
|
||||
prev: string;
|
||||
save: string;
|
||||
read: string;
|
||||
open: string;
|
||||
toggleSelect: string;
|
||||
};
|
||||
relevanceProfile: {
|
||||
categoryScores: Record<string, number>;
|
||||
feedScores: Record<string, number>;
|
||||
totalInteractions: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DBStats {
|
||||
size: number;
|
||||
path: string;
|
||||
articles: number;
|
||||
feeds: number;
|
||||
walEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface IDB {
|
||||
getFeeds(): Promise<Feed[]>;
|
||||
saveFeed(feed: Feed): Promise<void>;
|
||||
saveFeeds(feeds: Feed[]): Promise<void>;
|
||||
deleteFeed(id: string): Promise<void>;
|
||||
getCategories(): Promise<Category[]>;
|
||||
saveCategory(category: Category): Promise<void>;
|
||||
saveCategories(categories: Category[]): Promise<void>;
|
||||
deleteCategory(id: string): Promise<void>;
|
||||
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>;
|
||||
saveArticles(articles: Article[]): Promise<void>;
|
||||
searchArticles(query: string, limit?: number): Promise<Article[]>;
|
||||
getReadingHistory(days?: number): Promise<{ date: number, count: number }[]>;
|
||||
markAsRead(id: string): Promise<void>;
|
||||
bulkMarkRead(ids: string[]): Promise<void>;
|
||||
bulkDelete(ids: string[]): Promise<void>;
|
||||
bulkToggleSave(ids: string[]): Promise<void>;
|
||||
purgeOldContent(days: number): Promise<number>;
|
||||
updateArticleContent(id: string, content: string): Promise<void>;
|
||||
toggleSave(id: string): Promise<boolean>;
|
||||
getSavedArticles(): Promise<Article[]>;
|
||||
getSettings(): Promise<Settings>;
|
||||
saveSettings(settings: Settings): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
// Desktop specific
|
||||
getStats?(): Promise<DBStats>;
|
||||
vacuum?(): Promise<void>;
|
||||
integrityCheck?(): Promise<string>;
|
||||
}
|
||||
|
||||
const DB_NAME = 'WebNewsDB';
|
||||
const DB_VERSION = 5;
|
||||
|
||||
class IndexedDBImpl implements IDB {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async open(): Promise<IDBDatabase> {
|
||||
if (this.db) return this.db;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const transaction = (event.target as IDBOpenDBRequest).transaction!;
|
||||
if (!db.objectStoreNames.contains('feeds')) db.createObjectStore('feeds', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('categories')) db.createObjectStore('categories', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings', { keyPath: 'id' });
|
||||
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles') ? 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 });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getFeeds(): Promise<Feed[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('feeds', 'readonly');
|
||||
const request = transaction.objectStore('feeds').getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveFeed(feed: Feed): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('feeds', 'readwrite');
|
||||
const request = transaction.objectStore('feeds').put(feed);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveFeeds(feeds: Feed[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('feeds', 'readwrite');
|
||||
const store = transaction.objectStore('feeds');
|
||||
feeds.forEach(f => store.put(f));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFeed(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['feeds', 'articles'], 'readwrite');
|
||||
transaction.objectStore('feeds').delete(id);
|
||||
const articleStore = transaction.objectStore('articles');
|
||||
const request = articleStore.index('feedId').openKeyCursor(IDBKeyRange.only(id));
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursor>).result;
|
||||
if (cursor) { articleStore.delete(cursor.primaryKey); cursor.continue(); }
|
||||
};
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getCategories(): Promise<Category[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('categories', 'readonly');
|
||||
const request = transaction.objectStore('categories').getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveCategory(category: Category): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('categories', 'readwrite');
|
||||
const request = transaction.objectStore('categories').put(category);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveCategories(categories: Category[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('categories', 'readwrite');
|
||||
const store = transaction.objectStore('categories');
|
||||
categories.forEach(c => store.put(c));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['categories', 'feeds'], 'readwrite');
|
||||
transaction.objectStore('categories').delete(id);
|
||||
const feedStore = transaction.objectStore('feeds');
|
||||
const request = feedStore.getAll();
|
||||
request.onsuccess = () => {
|
||||
const feeds = request.result as Feed[];
|
||||
feeds.forEach(f => { if (f.categoryId === id) { f.categoryId = 'uncategorized'; feedStore.put(f); } });
|
||||
};
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const store = transaction.objectStore('articles');
|
||||
let request: IDBRequest<any[]>;
|
||||
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
|
||||
else request = store.index('pubDate').getAll();
|
||||
request.onsuccess = () => {
|
||||
const articles = request.result as Article[];
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
resolve(articles.slice(offset, offset + limit));
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveArticles(articles: Article[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
articles.forEach(article => store.put(article));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async searchArticles(query: string, limit = 50): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const store = transaction.objectStore('articles');
|
||||
const request = store.openCursor(null, 'prev');
|
||||
const results: Article[] = [];
|
||||
const lowQuery = query.toLowerCase();
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor && results.length < limit) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.title.toLowerCase().includes(lowQuery) || article.description.toLowerCase().includes(lowQuery) || (article.content && article.content.toLowerCase().includes(lowQuery))) {
|
||||
results.push(article);
|
||||
}
|
||||
cursor.continue();
|
||||
} else resolve(results);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const index = transaction.objectStore('articles').index('readAt');
|
||||
const startTime = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const request = index.openCursor(IDBKeyRange.lowerBound(startTime));
|
||||
const history: Record<string, number> = {};
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.readAt) {
|
||||
const date = new Date(article.readAt).toISOString().split('T')[0];
|
||||
history[date] = (history[date] || 0) + 1;
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(Object.entries(history).map(([date, count]) => ({ date: new Date(date).getTime(), count })));
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article && !article.read) {
|
||||
article.read = true;
|
||||
article.readAt = Date.now();
|
||||
store.put(article);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async bulkMarkRead(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
const now = Date.now();
|
||||
for (const id of ids) {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article && !article.read) { article.read = true; article.readAt = now; store.put(article); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
for (const id of ids) store.delete(id);
|
||||
}
|
||||
|
||||
async bulkToggleSave(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
for (const id of ids) {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async purgeOldContent(days: number): Promise<number> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const request = store.openCursor();
|
||||
let count = 0;
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.content && !article.saved && article.pubDate < cutoff) { delete article.content; cursor.update(article); count++; }
|
||||
cursor.continue();
|
||||
} else resolve(count);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async updateArticleContent(id: string, content: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article) { article.content = content; store.put(article); }
|
||||
};
|
||||
}
|
||||
|
||||
async toggleSave(id: string): Promise<boolean> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); resolve(article.saved); }
|
||||
else resolve(false);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSavedArticles(): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const request = transaction.objectStore('articles').index('saved').getAll(IDBKeyRange.only(1));
|
||||
request.onsuccess = () => {
|
||||
const articles = request.result as Article[];
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
resolve(articles);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction('settings', 'readonly');
|
||||
const request = transaction.objectStore('settings').get('main');
|
||||
request.onsuccess = () => {
|
||||
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: [],
|
||||
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
|
||||
};
|
||||
resolve({ ...defaults, ...(request.result || {}) });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async saveSettings(settings: Settings): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('settings', 'readwrite');
|
||||
const request = transaction.objectStore('settings').put({ ...settings, id: 'main' });
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['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.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CapacitorSQLiteDBImpl implements IDB {
|
||||
private sqlite: SQLiteConnection | null = null;
|
||||
private db: SQLiteDBConnection | null = null;
|
||||
|
||||
async open(): Promise<SQLiteDBConnection> {
|
||||
if (this.db) return this.db;
|
||||
if (!this.sqlite) this.sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
|
||||
const ret = await this.sqlite.checkConnectionsConsistency();
|
||||
const isConn = (await this.sqlite.isConnection(DB_NAME, false)).result;
|
||||
|
||||
if (ret.result && isConn) {
|
||||
this.db = await this.sqlite.retrieveConnection(DB_NAME, false);
|
||||
} else {
|
||||
this.db = await this.sqlite.createConnection(DB_NAME, false, "no-encryption", 1, false);
|
||||
}
|
||||
|
||||
await this.db.open();
|
||||
|
||||
const queries = [
|
||||
`CREATE TABLE IF NOT EXISTS categories (id TEXT PRIMARY KEY, name TEXT, "order" INTEGER);`,
|
||||
`CREATE TABLE IF NOT EXISTS feeds (id TEXT PRIMARY KEY, title TEXT, categoryId TEXT, "order" INTEGER, enabled INTEGER, fetchInterval 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_readAt ON articles(readAt);`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`
|
||||
];
|
||||
|
||||
for (const q of queries) {
|
||||
await this.db.execute(q);
|
||||
}
|
||||
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async getFeeds(): Promise<Feed[]> {
|
||||
const db = await this.open();
|
||||
const res = await db.query('SELECT * FROM feeds ORDER BY "order" ASC');
|
||||
return (res.values || []).map(f => ({ ...f, enabled: f.enabled === 1 }));
|
||||
}
|
||||
|
||||
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
|
||||
|
||||
async saveFeeds(feeds: Feed[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const f of feeds) {
|
||||
await db.run('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]);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFeed(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run('DELETE FROM articles WHERE feedId = ?', [id]);
|
||||
await db.run('DELETE FROM feeds WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async getCategories(): Promise<Category[]> {
|
||||
const db = await this.open();
|
||||
const res = await db.query('SELECT * FROM categories ORDER BY "order" ASC');
|
||||
return res.values || [];
|
||||
}
|
||||
|
||||
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
|
||||
|
||||
async saveCategories(categories: Category[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const c of categories) {
|
||||
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [c.id, c.name, c.order]);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run('UPDATE feeds SET categoryId = "uncategorized" WHERE categoryId = ?', [id]);
|
||||
await db.run('DELETE FROM categories WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
let res;
|
||||
if (feedId) {
|
||||
res = await db.query('SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?', [feedId, limit, offset]);
|
||||
} else {
|
||||
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 }));
|
||||
}
|
||||
|
||||
async saveArticles(articles: Article[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const a of articles) {
|
||||
await db.run(`INSERT OR REPLACE INTO articles
|
||||
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
async searchArticles(query: string, limit = 50): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
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]);
|
||||
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
}
|
||||
|
||||
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
|
||||
const db = await this.open();
|
||||
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const res = await db.query(`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
|
||||
FROM articles
|
||||
WHERE read = 1 AND readAt > ?
|
||||
GROUP BY date
|
||||
ORDER BY date DESC`, [cutoff]);
|
||||
return (res.values || []).map(row => ({
|
||||
date: new Date(row.date).getTime(),
|
||||
count: row.count
|
||||
}));
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [Date.now(), id]);
|
||||
}
|
||||
|
||||
async bulkMarkRead(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
const now = Date.now();
|
||||
for (const id of ids) {
|
||||
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [now, id]);
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const id of ids) await db.run('DELETE FROM articles WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async bulkToggleSave(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const id of ids) {
|
||||
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> {
|
||||
const db = await this.open();
|
||||
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]);
|
||||
return res.changes?.changes || 0;
|
||||
}
|
||||
|
||||
async updateArticleContent(id: string, content: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run('UPDATE articles SET content = ? WHERE id = ?', [content, id]);
|
||||
}
|
||||
|
||||
async toggleSave(id: string): Promise<boolean> {
|
||||
const db = await this.open();
|
||||
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]);
|
||||
return res.values?.[0]?.saved === 1;
|
||||
}
|
||||
|
||||
async getSavedArticles(): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
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 }));
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
const db = await this.open();
|
||||
const res = await db.query("SELECT value FROM settings WHERE key = 'main'");
|
||||
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: [],
|
||||
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
|
||||
};
|
||||
if (res.values && res.values.length > 0) {
|
||||
return { ...defaults, ...JSON.parse(res.values[0].value) };
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async saveSettings(settings: Settings): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [JSON.stringify(settings)]);
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run('DELETE FROM articles');
|
||||
await db.run('DELETE FROM feeds');
|
||||
await db.run('DELETE FROM categories');
|
||||
await db.run('DELETE FROM settings');
|
||||
}
|
||||
|
||||
async getStats(): Promise<DBStats> {
|
||||
const db = await this.open();
|
||||
const artRes = await db.query('SELECT COUNT(*) as count FROM articles');
|
||||
const feedRes = await db.query('SELECT COUNT(*) as count FROM feeds');
|
||||
return {
|
||||
size: 0, // Hard to get exact file size easily here
|
||||
path: 'Native SQLite',
|
||||
articles: artRes.values?.[0]?.count || 0,
|
||||
feeds: feedRes.values?.[0]?.count || 0,
|
||||
walEnabled: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WailsDBImpl implements IDB {
|
||||
private async call<T>(method: string, ...args: any[]): Promise<T> {
|
||||
const app = (window as any).go?.main?.App;
|
||||
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
|
||||
return Promise.race([
|
||||
app[method](...args),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Wails call ${method} timed out after 5s`)), 5000)
|
||||
)
|
||||
]) as Promise<T>;
|
||||
}
|
||||
|
||||
async getFeeds(): Promise<Feed[]> { return JSON.parse(await this.call('GetFeeds')); }
|
||||
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
|
||||
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> {
|
||||
const feeds = await this.getFeeds();
|
||||
for (const f of feeds) { if (f.categoryId === id) { f.categoryId = 'uncategorized'; await this.saveFeed(f); } }
|
||||
// Wait for feed updates then delete cat
|
||||
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 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));
|
||||
}
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
await this.call('MarkAsRead', id);
|
||||
}
|
||||
async bulkMarkRead(ids: string[]): Promise<void> { for (const id of ids) await this.markAsRead(id); }
|
||||
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> {
|
||||
const articles = await this.getArticles('', 0, 1000);
|
||||
const a = articles.find(art => art.id === id);
|
||||
if (a) { a.content = content; await this.call('UpdateArticle', JSON.stringify(a)); }
|
||||
}
|
||||
async toggleSave(id: string): Promise<boolean> {
|
||||
const articles = await this.getArticles('', 0, 1000);
|
||||
const a = articles.find(art => art.id === id);
|
||||
if (a) { a.saved = !a.saved; await this.call('UpdateArticle', JSON.stringify(a)); return a.saved; }
|
||||
return false;
|
||||
}
|
||||
async getSavedArticles(): Promise<Article[]> {
|
||||
const articles = await this.getArticles('', 0, 5000);
|
||||
return articles.filter(a => a.saved).sort((a, b) => b.pubDate - a.pubDate);
|
||||
}
|
||||
async getSettings(): Promise<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: [],
|
||||
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
|
||||
};
|
||||
const saved = await this.call<string>('GetSettings');
|
||||
if (!saved) return defaults; // Handle empty string case
|
||||
try {
|
||||
return { ...defaults, ...JSON.parse(saved) };
|
||||
} catch (e) {
|
||||
console.error("Failed to parse settings", e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
async saveSettings(settings: Settings): Promise<void> { await this.call('SaveSettings', JSON.stringify(settings)); }
|
||||
async clearAll(): Promise<void> { await this.call('ClearAll'); }
|
||||
|
||||
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),
|
||||
// correcting for race conditions where window.go might not be available at import time.
|
||||
class LazyDBWrapper implements IDB {
|
||||
private impl: IDB | null = null;
|
||||
|
||||
private getImpl(): IDB {
|
||||
if (this.impl) return this.impl;
|
||||
|
||||
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||
const isCapacitor = typeof window !== 'undefined' && (Capacitor.isNativePlatform());
|
||||
|
||||
if (isWails) {
|
||||
this.impl = new WailsDBImpl();
|
||||
} else if (isCapacitor) {
|
||||
this.impl = new CapacitorSQLiteDBImpl();
|
||||
} else {
|
||||
this.impl = new IndexedDBImpl();
|
||||
}
|
||||
|
||||
console.log(`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`);
|
||||
return this.impl;
|
||||
}
|
||||
|
||||
getFeeds() { return this.getImpl().getFeeds(); }
|
||||
saveFeed(feed: Feed) { return this.getImpl().saveFeed(feed); }
|
||||
saveFeeds(feeds: Feed[]) { return this.getImpl().saveFeeds(feeds); }
|
||||
deleteFeed(id: string) { return this.getImpl().deleteFeed(id); }
|
||||
getCategories() { return this.getImpl().getCategories(); }
|
||||
saveCategory(category: Category) { return this.getImpl().saveCategory(category); }
|
||||
saveCategories(categories: Category[]) { return this.getImpl().saveCategories(categories); }
|
||||
deleteCategory(id: string) { return this.getImpl().deleteCategory(id); }
|
||||
getArticles(feedId?: string, offset?: number, limit?: number) { return this.getImpl().getArticles(feedId, offset, limit); }
|
||||
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'; }
|
||||
}
|
||||
|
||||
export const db: IDB = new LazyDBWrapper();
|
||||
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
106
src/lib/opml.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Feed, Category } from './db';
|
||||
|
||||
export function exportToOPML(feeds: Feed[], categories: Category[]): string {
|
||||
const now = new Date().toUTCString();
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<opml version="2.0">
|
||||
<head>
|
||||
<title>Web News Subscriptions</title>
|
||||
<dateCreated>${now}</dateCreated>
|
||||
</head>
|
||||
<body>`;
|
||||
|
||||
// Group feeds by category
|
||||
const categorizedFeeds: Record<string, Feed[]> = {};
|
||||
feeds.forEach(f => {
|
||||
if (!categorizedFeeds[f.categoryId]) categorizedFeeds[f.categoryId] = [];
|
||||
categorizedFeeds[f.categoryId].push(f);
|
||||
});
|
||||
|
||||
// Add categories and their feeds
|
||||
[...categories].sort((a, b) => a.order - b.order).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 += `
|
||||
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
|
||||
});
|
||||
xml += `
|
||||
</outline>`;
|
||||
});
|
||||
|
||||
// Add uncategorized feeds
|
||||
const uncategorized = categorizedFeeds['uncategorized'] || [];
|
||||
[...uncategorized].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)}"/>`;
|
||||
});
|
||||
|
||||
xml += `
|
||||
</body>
|
||||
</opml>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Partial<Category>[] } {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
const outlines = doc.querySelectorAll('body > outline');
|
||||
|
||||
const feeds: Partial<Feed>[] = [];
|
||||
const categories: Partial<Category>[] = [];
|
||||
|
||||
outlines.forEach((outline) => {
|
||||
const text = outline.getAttribute('text') || outline.getAttribute('title') || 'Uncategorized';
|
||||
const type = outline.getAttribute('type');
|
||||
|
||||
if (type === 'rss') {
|
||||
// Direct feed outline
|
||||
feeds.push(parseFeedOutline(outline, 'uncategorized', feeds.length));
|
||||
} else {
|
||||
// Category outline
|
||||
const categoryId = Math.random().toString(36).substring(2, 9);
|
||||
categories.push({
|
||||
id: categoryId,
|
||||
name: text,
|
||||
order: categories.length
|
||||
});
|
||||
|
||||
const childFeeds = outline.querySelectorAll('outline[type="rss"]');
|
||||
childFeeds.forEach((child, childIndex) => {
|
||||
feeds.push(parseFeedOutline(child, categoryId, childIndex));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { feeds, categories };
|
||||
}
|
||||
|
||||
function parseFeedOutline(el: Element, categoryId: string, order: number): Partial<Feed> {
|
||||
return {
|
||||
id: el.getAttribute('xmlUrl') || '',
|
||||
title: el.getAttribute('text') || el.getAttribute('title') || '',
|
||||
siteUrl: el.getAttribute('htmlUrl') || '',
|
||||
categoryId,
|
||||
order,
|
||||
enabled: true,
|
||||
consecutiveErrors: 0,
|
||||
fetchInterval: 30,
|
||||
lastFetched: 0
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHTML(str: string): string {
|
||||
return str.replace(/[&<>"']/g, m => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[m] || m);
|
||||
}
|
||||
|
||||