refactor: improve database initialization and news fetching structure

This commit is contained in:
2025-08-01 21:57:13 +02:00
parent 3a1c817381
commit e22f3a627a
8 changed files with 552 additions and 400 deletions

File diff suppressed because it is too large Load Diff

34
backend/app/schema.sql Normal file
View File

@@ -0,0 +1,34 @@
-- Database schema for Owly News Summariser
-- News table to store articles
CREATE TABLE IF NOT EXISTS news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
url TEXT NOT NULL,
published TIMESTAMP NOT NULL,
country TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster queries on published date
CREATE INDEX IF NOT EXISTS idx_news_published ON news(published);
-- Feeds table to store RSS feed sources
CREATE TABLE IF NOT EXISTS feeds (
id INTEGER PRIMARY KEY,
country TEXT,
url TEXT UNIQUE NOT NULL
);
-- Settings table for application configuration
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
val TEXT NOT NULL
);
-- Meta table for application metadata
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
val TEXT NOT NULL
);

View File

@@ -1,8 +1,29 @@
# URL for the Ollama service # URL for the Ollama service
OLLAMA_HOST=http://localhost:11434 OLLAMA_HOST=http://localhost:11434
# Interval for scheduled news fetching in hours (minimum: 0.5) # Interval for scheduled news fetching in hours
CRON_HOURS=1 CRON_HOURS=1
# Minimum interval for scheduled news fetching in hours
MIN_CRON_HOURS=0.5
# Cooldown period in minutes between manual syncs
SYNC_COOLDOWN_MINUTES=30
# LLM model to use for summarization
LLM_MODEL=qwen2:7b-instruct-q4_K_M
# Timeout in seconds for LLM requests
LLM_TIMEOUT_SECONDS=180
# Timeout in seconds for Ollama API requests
OLLAMA_API_TIMEOUT_SECONDS=10
# Timeout in seconds for article fetching
ARTICLE_FETCH_TIMEOUT=30
# Maximum length of article content to process
MAX_ARTICLE_LENGTH=5000
# SQLite database connection string # SQLite database connection string
DATABASE_URL=sqlite:///./newsdb.sqlite DB_NAME=owlynews.sqlite3

View File

@@ -4,6 +4,7 @@ import {useNews} from './stores/useNews';
import FeedManager from './components/FeedManager.vue'; import FeedManager from './components/FeedManager.vue';
import CronSlider from './components/CronSlider.vue'; import CronSlider from './components/CronSlider.vue';
import SyncButton from './components/SyncButton.vue'; import SyncButton from './components/SyncButton.vue';
import NewsRefreshButton from './components/NewsRefreshButton.vue';
import ModelStatus from './components/ModelStatus.vue'; import ModelStatus from './components/ModelStatus.vue';
const news = useNews(); const news = useNews();
@@ -12,31 +13,28 @@ const filters = ref({country: 'DE'});
onMounted(async () => { onMounted(async () => {
await news.loadLastSync(); await news.loadLastSync();
await news.sync(filters.value); await news.sync(filters.value);
await news.getNews(filters.value);
}); });
</script> </script>
<template> <template>
<main class="max-w-4xl mx-auto p-4 space-y-6"> <main class="max-w-4xl mx-auto p-4 space-y-6">
<h1 class="text-2xl font-bold">📰 Local News Summariser</h1> <h1 class="text-2xl font-bold">📰 Local News Summariser</h1>
<div class="grid md:grid-cols-3 gap-4"> <div class="grid md:grid-cols-4 gap-4">
<CronSlider/> <CronSlider/>
<SyncButton/> <SyncButton/>
<NewsRefreshButton/>
<ModelStatus/> <ModelStatus/>
</div> </div>
<FeedManager/> <FeedManager/>
<section v-if="news.offline" class="p-2 bg-yellow-100 border-l-4 border-yellow-500">
Offline Datenstand: {{ new Date(news.lastSync).toLocaleString() }}
</section>
<article v-for="a in news.articles" :key="a.id" class="bg-white rounded p-4 shadow"> <article v-for="a in news.articles" :key="a.id" class="bg-white rounded p-4 shadow">
<h2 class="font-semibold">{{ a.title }}</h2> <h2 class="font-semibold">{{ a.title }}</h2>
<p class="text-sm text-gray-600">{{ new Date(a.published).toLocaleString() }} {{ <p class="text-sm text-gray-600">{{ new Date(a.published).toLocaleString() }} {{ a.country }}</p>
a.source <p>{{a.published}}</p>
}}</p> <p class="mt-2">{{ a.description }}</p>
<p class="mt-2">{{ a.summary_de }}</p> <p class="italic mt-2 text-sm text-gray-700">Added: {{ new Date(a.created_at).toLocaleString() }}</p>
<p class="italic mt-2 text-sm text-gray-700">{{ a.summary_en }}</p>
<a :href="a.url" target="_blank" class="text-blue-600 hover:underline">Original </a> <a :href="a.url" target="_blank" class="text-blue-600 hover:underline">Original </a>
</article> </article>
</main> </main>

View File

@@ -12,7 +12,7 @@ onMounted(async () => {
async function update() { async function update() {
saving.value = true; saving.value = true;
await fetch('/settings/cron', { await fetch('/settings/cron', {
method: 'PUT', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hours: hours.value}) body: JSON.stringify({hours: hours.value})
}); });

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useNews } from '../stores/useNews';
const news = useNews();
const isLoading = ref(false);
async function refreshNews() {
isLoading.value = true;
try {
await news.getNews({country: 'DE'});
} finally {
isLoading.value = false;
}
}
</script>
<template>
<button
@click="refreshNews"
class="px-4 py-2 rounded bg-green-600 hover:bg-green-700 text-white flex items-center justify-center"
:disabled="isLoading"
>
<span v-if="isLoading" class="animate-spin mr-2"></span>
<span>Refresh News</span>
</button>
</template>

View File

@@ -1,17 +1,16 @@
import {defineStore} from 'pinia'; import {defineStore} from 'pinia';
import {set, get} from 'idb-keyval';
export const useNews = defineStore('news', { export const useNews = defineStore('news', {
state: () => ({ state: () => ({
articles: [] as { articles: [] as {
id: number, id: number,
published: number,
title: string, title: string,
description: string,
url: string, url: string,
source: string, published: number,
summary_de: string, country: string,
summary_en: string created_at: number
}[], lastSync: 0, offline: false }[], lastSync: 0
}), }),
actions: { actions: {
async loadLastSync() { async loadLastSync() {
@@ -22,23 +21,31 @@ export const useNews = defineStore('news', {
return Date.now() - this.lastSync > 30 * 60 * 1000; // 30min guard return Date.now() - this.lastSync > 30 * 60 * 1000; // 30min guard
}, },
async sync(filters: Record<string, string>) { async sync(filters: Record<string, string>) {
try { if (!this.canManualSync()) {
if (!this.canManualSync()) throw new Error('Too soon'); console.log('Too soon to sync again');
const q = new URLSearchParams(filters).toString(); return;
const res = await fetch(`/news?${q}`); }
if (!res.ok) throw new Error('network');
const q = new URLSearchParams(filters).toString();
const res = await fetch(`/news?${q}`);
if (res.ok) {
const data = await res.json(); const data = await res.json();
this.articles = data; this.articles = data;
this.lastSync = Date.now(); this.lastSync = Date.now();
await set(JSON.stringify(filters), data);
this.offline = false;
} catch (e) {
const cached = await get(JSON.stringify(filters));
if (cached) {
this.articles = cached;
this.offline = true;
}
} }
},
async getNews(filters: Record<string, string> = {}) {
const q = new URLSearchParams(filters).toString();
const res = await fetch(`/news?${q}`);
if (res.ok) {
const data = await res.json();
this.articles = data;
return data;
}
return [];
} }
} }
}); });

View File

@@ -5,7 +5,6 @@ import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx'; import vueJsx from '@vitejs/plugin-vue-jsx';
import {defineConfig} from 'vite'; import {defineConfig} from 'vite';
import vueDevTools from 'vite-plugin-vue-devtools'; import vueDevTools from 'vite-plugin-vue-devtools';
import {VitePWA} from "vite-plugin-pwa";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -13,22 +12,7 @@ export default defineConfig({
vue(), vue(),
vueJsx(), vueJsx(),
vueDevTools(), vueDevTools(),
tailwindcss(), tailwindcss()
VitePWA({
registerType: 'autoUpdate',
workbox: {
runtimeCaching: [
{
urlPattern: /\/news\?/,
handler: 'NetworkFirst',
options: {
cacheName: 'news-api',
networkTimeoutSeconds: 3
}
}
]
}
})
], ],
build: {outDir: 'dist'}, build: {outDir: 'dist'},
resolve: { resolve: {