0.1.0
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 28s
CI / build-backend (push) Failing after 31s
CI / build-frontend (push) Successful in 50s

This commit is contained in:
2025-12-26 21:31:05 -06:00
parent 10ea615b8f
commit 28273473e1
118 changed files with 13787 additions and 0 deletions

45
.air.toml Normal file
View 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
View 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
View 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

View 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 }}

View 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 }}

View 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

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
node_modules
.svelte-kit
build
dist
.DS_Store

8
.prettierrc Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

54
android/app/build.gradle Normal file
View 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")
}

View 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
View 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.** { *; }

View File

@@ -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());
}
}

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

View File

@@ -0,0 +1,5 @@
package com.quad4.webnews;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

View 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();
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

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

View File

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

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

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

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

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

View File

@@ -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
View 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
}

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

View File

Binary file not shown.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

6
scripts/build.sh Normal file
View 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
View 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

View 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
View 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
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

65
src/app.css Normal file
View 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
View 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
View 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>

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

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

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

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

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

View 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
View 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
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

106
src/lib/opml.ts Normal file
View 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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[m] || m);
}

Some files were not shown because too many files have changed in this diff Show More