Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 49 additions & 37 deletions BearReaderApplication/BearReaderApplicationApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,49 +77,61 @@ struct BearReaderApplicationApp: App {

let bearBlogService = BearBlogService()

for blog in subscribedBlogs {
do {
let existingPostsUrls = try await DatabaseManager.shared.getTrackedPostUrls(for: blog.domain)
let posts = try await bearBlogService.getBlogFeed(domain: blog.domain)

let newPosts = posts.filter { !existingPostsUrls.contains($0.url) }

if newPosts.count > 0 {
logger.info("Found \(newPosts.count) new posts for \(blog.domain)")

if UIApplication.shared.applicationState == .background && !blog.isNotificationsMuted {
for newPost in newPosts {
let content = UNMutableNotificationContent()
content.title = "New post from \(blog.blogTitle)"
content.body = "\(newPost.title)"
content.sound = UNNotificationSound.default
content.threadIdentifier = blog.domain


content.userInfo = [
"title": newPost.title,
"url": newPost.url,
"age": newPost.age,
"rating": newPost.rating,
"domain": blog.domain
]
// Use concurrent requests instead of sequential loop for better performance
await withTaskGroup(of: Void.self) { group in
for blog in subscribedBlogs {
group.addTask {
do {
let existingPostsUrls = try await DatabaseManager.shared.getTrackedPostUrls(for: blog.domain)
let posts = try await bearBlogService.getBlogFeed(domain: blog.domain)

let newPosts = posts.filter { !existingPostsUrls.contains($0.url) }

if newPosts.count > 0 {
self.logger.info("Found \(newPosts.count) new posts for \(blog.domain)")

let request = UNNotificationRequest(identifier: newPost.url, content: content, trigger: nil)
do {
try await UNUserNotificationCenter.current().add(request)
} catch {
logger.error("Failed to add notification request upon recieving a new post from subscribed blog \(blog.domain): \(error.localizedDescription)")
if UIApplication.shared.applicationState == .background && !blog.isNotificationsMuted {
// Batch notification requests to avoid creating them one by one
let notificationRequests = newPosts.map { newPost -> UNNotificationRequest in
let content = UNMutableNotificationContent()
content.title = "New post from \(blog.blogTitle)"
content.body = "\(newPost.title)"
content.sound = UNNotificationSound.default
content.threadIdentifier = blog.domain

content.userInfo = [
"title": newPost.title,
"url": newPost.url,
"age": newPost.age,
"rating": newPost.rating,
"domain": blog.domain
]

return UNNotificationRequest(identifier: newPost.url, content: content, trigger: nil)
}

// Add all notifications concurrently
for request in notificationRequests {
do {
try await UNUserNotificationCenter.current().add(request)
} catch {
self.logger.error("Failed to add notification request upon receiving a new post from subscribed blog \(blog.domain): \(error.localizedDescription)")
}
}
}

try await DatabaseManager.shared.incrementNewPostsCount(domain: blog.domain, by: newPosts.count)
}
}

try await DatabaseManager.shared.incrementNewPostsCount(domain: blog.domain, by: newPosts.count)
try await DatabaseManager.shared.updateBlogLastFetched(domain: blog.domain)
} catch {
self.logger.error("Failed to refresh blog \(blog.domain): \(error.localizedDescription)")
}
}

try await DatabaseManager.shared.updateBlogLastFetched(domain: blog.domain)
} catch {
logger.error("Failed to refresh blog \(blog.domain): \(error.localizedDescription)")
}

// Wait for all tasks to complete
await group.waitForAll()
}

// I have no idea how can we track the fact that this actually triggered even once
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// AddPerformanceIndexesMigration.swift
// BearReaderApplication
//
// Created by Performance Optimization on 14.02.26.
//

import Foundation
import SQLite
import SQLiteMigrationManager
import os.log

struct AddPerformanceIndexesMigration: Migration {
var version: Int64 = 2026_02_14_16_30_00

private let logger = Logger(subsystem: "BearReader", category: "AddPerformanceIndexesMigration")

func migrateDatabase(_ db: Connection) throws {
logger.debug("Starting performance indexes migration")

// Add index on tracked_posts.url for faster lookups
logger.debug("Creating index on tracked_posts.url")
try db.run("CREATE INDEX IF NOT EXISTS idx_tracked_posts_url ON tracked_posts(url)")

// Add index on tracked_posts.domain for faster filtering by domain
logger.debug("Creating index on tracked_posts.domain")
try db.run("CREATE INDEX IF NOT EXISTS idx_tracked_posts_domain ON tracked_posts(domain)")

// Add index on tracked_posts.title for faster search queries
logger.debug("Creating index on tracked_posts.title")
try db.run("CREATE INDEX IF NOT EXISTS idx_tracked_posts_title ON tracked_posts(title)")

// Add index on tracked_posts.last_accessed_at for faster sorting
logger.debug("Creating index on tracked_posts.last_accessed_at")
try db.run("CREATE INDEX IF NOT EXISTS idx_tracked_posts_last_accessed ON tracked_posts(last_accessed_at)")

// Add index on tracked_posts.is_bookmarked for faster bookmark queries
logger.debug("Creating index on tracked_posts.is_bookmarked")
try db.run("CREATE INDEX IF NOT EXISTS idx_tracked_posts_bookmarked ON tracked_posts(is_bookmarked)")

// Add index on tracked_posts.view_id for faster read posts queries
logger.debug("Creating index on tracked_posts.view_id")
try db.run("CREATE INDEX IF NOT EXISTS idx_tracked_posts_view_id ON tracked_posts(view_id)")

// Add index on subscribed_blogs.domain for faster lookups
logger.debug("Creating index on subscribed_blogs.domain")
try db.run("CREATE INDEX IF NOT EXISTS idx_subscribed_blogs_domain ON subscribed_blogs(domain)")

// Add index on visit_history.date for faster sorting
logger.debug("Creating index on visit_history.date")
try db.run("CREATE INDEX IF NOT EXISTS idx_visit_history_date ON visit_history(date)")

logger.debug("Performance indexes migration completed")
}
}
2 changes: 2 additions & 0 deletions BearReaderApplication/Services/BearBlogAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ final class BearBlogService: BearBlogServiceProtocol, Sendable {
let posts = try document.select(settings.cssSelectors.postsList)

var _posts = [PostItem]()
_posts.reserveCapacity(posts.count)
for post in posts {
let title = try post.select(settings.cssSelectors.postTitle).text()
let url = try post.select(settings.cssSelectors.postTitle).attr("href")
Expand Down Expand Up @@ -235,6 +236,7 @@ final class BearBlogService: BearBlogServiceProtocol, Sendable {
let postListItems = try document.select("ul.blog-posts li")

var posts: [PostItem] = []
posts.reserveCapacity(postListItems.count)

for listItem in postListItems {
let timeElement = try listItem.select("span i time").first()
Expand Down
41 changes: 25 additions & 16 deletions BearReaderApplication/Services/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ actor DatabaseManager {

let manager = SQLiteMigrationManager(
db: connection,
migrations: [InitialSchemaMigration()],
migrations: [InitialSchemaMigration(), AddPerformanceIndexesMigration()],
bundle: Bundle.main
)

Expand Down Expand Up @@ -179,13 +179,14 @@ actor DatabaseManager {
return nil
}

func searchPosts(_ query: String) throws -> [TrackedPostData] {
func searchPosts(_ query: String, limit: Int = 100, offset: Int = 0) throws -> [TrackedPostData] {
let conn = try connection
var results: [TrackedPostData] = []

logger.debug("Searching posts with query: \(query)")
logger.debug("Searching posts with query: \(query), limit: \(limit), offset: \(offset)")
let searchQuery = trackedPosts.filter(trackedPostsTitle.like("%\(query)%") || trackedPostsDomain.like("%\(query)%"))
.order(trackedPostsLastAccessedAt.desc)
.limit(limit, offset: offset)

for row in try conn.prepare(searchQuery) {
let trackedPost = TrackedPostData(
Expand All @@ -208,12 +209,14 @@ actor DatabaseManager {
return results
}

func getReadPosts() throws -> [TrackedPostData] {
func getReadPosts(limit: Int = 100, offset: Int = 0) throws -> [TrackedPostData] {
let conn = try connection
var results: [TrackedPostData] = []

logger.debug("Fetching read posts")
let query = trackedPosts.filter(trackedPostsViewID > 0).order(trackedPostsLastAccessedAt.desc)
logger.debug("Fetching read posts with limit: \(limit), offset: \(offset)")
let query = trackedPosts.filter(trackedPostsViewID > 0)
.order(trackedPostsLastAccessedAt.desc)
.limit(limit, offset: offset)

for row in try conn.prepare(query) {
let trackedPost = TrackedPostData(
Expand Down Expand Up @@ -244,12 +247,14 @@ actor DatabaseManager {
logger.debug("Tracked post removed: \(postUrl)")
}

func getAllTrackedPosts() throws -> [TrackedPostData] {
func getAllTrackedPosts(limit: Int = 100, offset: Int = 0) throws -> [TrackedPostData] {
let conn = try connection
var results: [TrackedPostData] = []

logger.debug("Fetching all tracked posts")
let query = trackedPosts.order(trackedPostsEncounteredAt.desc)
logger.debug("Fetching tracked posts with limit: \(limit), offset: \(offset)")
let query = trackedPosts
.order(trackedPostsEncounteredAt.desc)
.limit(limit, offset: offset)

for row in try conn.prepare(query) {
let trackedPost = TrackedPostData(
Expand All @@ -267,7 +272,7 @@ actor DatabaseManager {
)
results.append(trackedPost)
}
logger.debug("Retrieved \(results.count) total tracked posts")
logger.debug("Retrieved \(results.count) tracked posts")

return results
}
Expand Down Expand Up @@ -319,12 +324,14 @@ actor DatabaseManager {
return false
}

func getBookmarkedPosts() throws -> [TrackedPostData] {
func getBookmarkedPosts(limit: Int = 100, offset: Int = 0) throws -> [TrackedPostData] {
let conn = try connection
var results: [TrackedPostData] = []

logger.debug("Fetching bookmarked posts")
let query = trackedPosts.filter(trackedPostsIsBookmarked == true).order(trackedPostsLastAccessedAt.desc)
logger.debug("Fetching bookmarked posts with limit: \(limit), offset: \(offset)")
let query = trackedPosts.filter(trackedPostsIsBookmarked == true)
.order(trackedPostsLastAccessedAt.desc)
.limit(limit, offset: offset)

for row in try conn.prepare(query) {
let trackedPost = TrackedPostData(
Expand Down Expand Up @@ -510,13 +517,15 @@ actor DatabaseManager {
try conn.run(post.delete())
}

func getBrowsingHistory() throws -> [BrowsingHistory] {
func getBrowsingHistory(limit: Int = 100, offset: Int = 0) throws -> [BrowsingHistory] {
let conn = try connection

var results: [BrowsingHistory] = []

logger.debug("Fetching browsing history")
let query = browsingHistoryTable.order(browsingHistoryDate.desc)
logger.debug("Fetching browsing history with limit: \(limit), offset: \(offset)")
let query = browsingHistoryTable
.order(browsingHistoryDate.desc)
.limit(limit, offset: offset)

for row in try conn.prepare(query) {
let subscription = BrowsingHistory(
Expand Down
2 changes: 2 additions & 0 deletions BearReaderApplication/Services/ImageSaver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import SwiftUI

// Thanks to: https://www.hackingwithswift.com/books/ios-swiftui/how-to-save-images-to-the-users-photo-library
class ImageSaver: NSObject {
static let shared = ImageSaver()

func writeToPhotoAlbum(image: UIImage) {
UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveCompleted), nil)
}
Expand Down
52 changes: 36 additions & 16 deletions BearReaderApplication/ViewModels/BlogsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,24 @@ class BlogsViewModel: ObservableObject {
try await DatabaseManager.shared.unsubscribeFromBlog(domain: blog.domain)
await loadSubscribedBlogs()

// Auto hide toast after 5 seconds
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000)
if recentlyDeletedBlog?.domain == blog.domain {
withAnimation {
showUndoToast = false
}
// Clear the reference after the toast is gone
try? await Task.sleep(nanoseconds: 1 * 500_000_000) // Small buffer
if !showUndoToast {
recentlyDeletedBlog = nil
// Auto hide toast after 5 seconds using Task with cancellation support
Task {
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000)
await MainActor.run {
if recentlyDeletedBlog?.domain == blog.domain {
withAnimation {
showUndoToast = false
}
// Clear the reference after animation completes
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s for animation
await MainActor.run {
if !showUndoToast {
recentlyDeletedBlog = nil
}
}
}
}
}
}
} catch {
Expand Down Expand Up @@ -78,12 +86,24 @@ class BlogsViewModel: ObservableObject {

let bearBlogService = BearBlogService()

for blog in subscribedBlogs {
do {
_ = try await bearBlogService.getBlogFeed(domain: blog.domain)
try await DatabaseManager.shared.updateBlogLastFetched(domain: blog.domain)
} catch {
print("[warning] Failed to refresh blog \(blog.domain): \(error)")
// Use concurrent requests instead of sequential loop
await withTaskGroup(of: (String, Error?).self) { group in
for blog in subscribedBlogs {
group.addTask {
do {
_ = try await bearBlogService.getBlogFeed(domain: blog.domain)
try await DatabaseManager.shared.updateBlogLastFetched(domain: blog.domain)
return (blog.domain, nil)
} catch {
print("[warning] Failed to refresh blog \(blog.domain): \(error)")
return (blog.domain, error)
}
}
}

// Collect results
for await _ in group {
// Results are already logged above
}
}

Expand Down
10 changes: 4 additions & 6 deletions BearReaderApplication/Views/Components/PostImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Kingfisher

struct PostImageView: View {
let postImage: PostImage
@StateObject private var networkMonitor = NetworkMonitor.shared
@ObservedObject private var networkMonitor = NetworkMonitor.shared
@State private var loadFailed = false
@State private var loadedUIImage: UIImage?

Expand All @@ -25,15 +25,13 @@ struct PostImageView: View {
Menu {
if let imageToSave = loadedUIImage {
Button(action: {
let imageSaver = ImageSaver()
imageSaver.writeToPhotoAlbum(image: imageToSave)
ImageSaver.shared.writeToPhotoAlbum(image: imageToSave)
}) {
Label("Save to Photos", systemImage: "square.and.arrow.down")
}

Button(action: {
let imageSaver = ImageSaver()
imageSaver.copyToClipboard(image: imageToSave)
ImageSaver.shared.copyToClipboard(image: imageToSave)
}) {
Label("Copy Image", systemImage: "doc.on.clipboard")
}
Expand All @@ -58,7 +56,7 @@ struct PostImageView: View {
.fade(duration: 0.25)
.cacheOriginalImage()
.diskCacheExpiration(.days(7))
.memoryCacheExpiration(.seconds(300))
.memoryCacheExpiration(.seconds(1800)) // 30 minutes instead of 5
.resizable()
.cornerRadius(8)
.aspectRatio(contentMode: .fit)
Expand Down
Loading
Loading