diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift
index 51985e00d..f589005b9 100644
--- a/Mac/AppDefaults.swift
+++ b/Mac/AppDefaults.swift
@@ -43,6 +43,11 @@ final class AppDefaults: Sendable {
static let defaultBrowserID = "defaultBrowserID"
static let currentThemeName = "currentThemeName"
static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled"
+ static let ollamaBaseURL = "OllamaBaseURL"
+ static let ollamaModel = "OllamaModel"
+ static let ollamaPreferredLanguage = "OllamaPreferredLanguage"
+ static let ollamaPreloadCount = "OllamaPreloadCount"
+ static let ollamaAutoTranslate = "OllamaAutoTranslate"
// Hidden prefs
static let showDebugMenu = "ShowDebugMenu"
@@ -344,7 +349,12 @@ final class AppDefaults: Sendable {
Key.refreshInterval: RefreshInterval.every2Hours.rawValue,
Key.showDebugMenu: showDebugMenu,
Key.currentThemeName: Self.defaultThemeName,
- Key.articleContentJavascriptEnabled: true
+ Key.articleContentJavascriptEnabled: true,
+ Key.ollamaBaseURL: "http://localhost:11434/api",
+ Key.ollamaModel: "llama3",
+ Key.ollamaPreferredLanguage: "Chinese",
+ Key.ollamaPreloadCount: 10,
+ Key.ollamaAutoTranslate: false
]
UserDefaults.standard.register(defaults: defaults)
diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift
index a64e8ec2a..2e5b9942d 100644
--- a/Mac/MainWindow/Detail/DetailWebViewController.swift
+++ b/Mac/MainWindow/Detail/DetailWebViewController.swift
@@ -230,11 +230,10 @@ extension DetailWebViewController: WKNavigationDelegate, WKUIDelegate {
}
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- guard let windowScrollY else {
- return
+ if let windowScrollY {
+ webView.evaluateJavaScript("window.scrollTo(0, \(windowScrollY));")
+ self.windowScrollY = nil
}
- webView.evaluateJavaScript("window.scrollTo(0, \(windowScrollY));")
- self.windowScrollY = nil
}
// WKUIDelegate
@@ -296,17 +295,72 @@ private extension DetailWebViewController {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
}
+ var bodyHTML = rendering.html
+ var requestTranslation = false
+ var textToTranslate = ""
+
+ if let article = self.article, UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") {
+ textToTranslate = article.body ?? ""
+ if case .extracted(_, let extractedArticle, _) = state, let content = extractedArticle.content {
+ textToTranslate = content
+ }
+ textToTranslate = textToTranslate.strippingHTML(maxCharacters: 4000)
+
+ if !textToTranslate.isEmpty {
+ if let cached = OllamaClient.shared.cachedTranslation(articleID: article.articleID) {
+ bodyHTML += "
\(cached)
"
+ } else {
+ bodyHTML += "
Translating...
"
+ requestTranslation = true
+ }
+ }
+ }
+
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
- "body": rendering.html
+ "body": bodyHTML
]
var html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
html = ArticleRenderingSpecialCases.filterHTMLIfNeeded(baseURL: rendering.baseURL, html: html)
WebViewConfiguration.addContentBlockingRules(to: webView)
webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL))
+
+ if requestTranslation {
+ OllamaClient.shared.translate(articleID: self.article!.articleID, text: textToTranslate) { [weak self, articleID = self.article!.articleID] result in
+ DispatchQueue.main.async {
+ guard let self = self, self.article?.articleID == articleID else { return }
+ if case .success(let translated) = result {
+ self.injectTranslation(translated)
+ } else if case .failure(let error) = result {
+ self.injectTranslation("Translation failed: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+ }
+
+ func injectTranslation(_ translatedText: String) {
+ let encoded = translatedText
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+
+ let js = """
+ function updateTranslation() {
+ var translationDiv = document.getElementById('ollama-translation');
+ if (translationDiv) {
+ translationDiv.innerText = \"\(encoded)\";
+ } else {
+ setTimeout(updateTranslation, 100);
+ }
+ }
+ updateTranslation();
+ """
+ webView.evaluateJavaScript(js)
}
func fetchScrollInfo() async -> ScrollInfo? {
diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift
index d7ac791f1..54326c7ec 100644
--- a/Mac/MainWindow/Timeline/TimelineViewController.swift
+++ b/Mac/MainWindow/Timeline/TimelineViewController.swift
@@ -939,6 +939,21 @@ extension TimelineViewController: NSTableViewDelegate {
if !article.status.read {
markArticles(Set([article]), statusKey: .read, flag: true)
}
+
+ if UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") {
+ let count = UserDefaults.standard.object(forKey: "OllamaPreloadCount") != nil ? UserDefaults.standard.integer(forKey: "OllamaPreloadCount") : 10
+ if count > 0, let index = articles.firstIndex(of: article) {
+ let nextIndex = index + 1
+ if nextIndex < articles.count {
+ let limit = min(articles.count, nextIndex + count)
+ let itemsToPreload = articles[nextIndex.. (id: String, text: String)? in
+ let text = (a.body ?? "").strippingHTML(maxCharacters: 4000)
+ return text.isEmpty ? nil : (a.articleID, text)
+ }
+ OllamaClient.shared.preloadTranslations(items: itemsToPreload)
+ }
+ }
+ }
}
selectionDidChange(selectedArticles)
diff --git a/Mac/Preferences/PreferencesWindowController.swift b/Mac/Preferences/PreferencesWindowController.swift
index 5dcb8f361..ec7a36bc3 100644
--- a/Mac/Preferences/PreferencesWindowController.swift
+++ b/Mac/Preferences/PreferencesWindowController.swift
@@ -25,6 +25,7 @@ private struct ToolbarItemIdentifier {
static let General = "General"
static let Accounts = "Accounts"
static let Advanced = "Advanced"
+ static let Ollama = "Ollama"
}
final class PreferencesWindowController: NSWindowController, NSToolbarDelegate {
@@ -42,6 +43,9 @@ final class PreferencesWindowController: NSWindowController, NSToolbarDelegate {
specs += [PreferencesToolbarItemSpec(identifierRawValue: ToolbarItemIdentifier.Advanced,
name: NSLocalizedString("Advanced", comment: "Preferences"),
image: Assets.Images.preferencesToolbarAdvanced)]
+ specs += [PreferencesToolbarItemSpec(identifierRawValue: ToolbarItemIdentifier.Ollama,
+ name: NSLocalizedString("Translation", comment: "Preferences"),
+ image: NSImage(systemSymbolName: "globe", accessibilityDescription: nil))]
return specs
}()
@@ -152,6 +156,12 @@ private extension PreferencesWindowController {
return cachedViewController
}
+ if identifier == ToolbarItemIdentifier.Ollama {
+ let viewController = OllamaPreferencesViewController()
+ viewControllers[identifier] = viewController
+ return viewController
+ }
+
let storyboard = NSStoryboard(name: NSStoryboard.Name("Preferences"), bundle: nil)
guard let viewController = storyboard.instantiateController(withIdentifier: NSStoryboard.SceneIdentifier(identifier)) as? NSViewController else {
assertionFailure("Unknown preferences view controller: \(identifier)")
@@ -190,3 +200,130 @@ private extension PreferencesWindowController {
}
}
}
+
+final class OllamaPreferencesViewController: NSViewController {
+
+ private let baseURLTextField = NSTextField()
+ private let modelTextField = NSTextField()
+ private let languageTextField = NSTextField()
+ private let autoTranslateCheckbox = NSButton(checkboxWithTitle: NSLocalizedString("Auto-Translate Articles", comment: ""), target: nil, action: nil)
+ private let preloadCountTextField = NSTextField()
+
+ init() {
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ self.view = NSView(frame: NSRect(x: 0, y: 0, width: 512, height: 250))
+
+ let stackView = NSStackView()
+ stackView.orientation = .vertical
+ stackView.alignment = .leading
+ stackView.spacing = 16
+ stackView.edgeInsets = NSEdgeInsets(top: 20, left: 40, bottom: 20, right: 40)
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+
+ self.view.addSubview(stackView)
+ NSLayoutConstraint.activate([
+ stackView.topAnchor.constraint(equalTo: view.topAnchor),
+ stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+
+ func addFormRow(title: String, control: NSView) {
+ let rowStack = NSStackView()
+ rowStack.orientation = .horizontal
+ rowStack.alignment = .firstBaseline
+ rowStack.spacing = 8
+
+ let label = NSTextField(labelWithString: title)
+ label.alignment = .right
+ label.widthAnchor.constraint(equalToConstant: 120).isActive = true
+
+ rowStack.addArrangedSubview(label)
+ rowStack.addArrangedSubview(control)
+ stackView.addArrangedSubview(rowStack)
+ }
+
+ // Base URL
+ baseURLTextField.translatesAutoresizingMaskIntoConstraints = false
+ baseURLTextField.widthAnchor.constraint(equalToConstant: 250).isActive = true
+ baseURLTextField.stringValue = UserDefaults.standard.string(forKey: "OllamaBaseURL") ?? "http://localhost:11434/api"
+ addFormRow(title: NSLocalizedString("Base URL:", comment: ""), control: baseURLTextField)
+
+ // Model
+ modelTextField.translatesAutoresizingMaskIntoConstraints = false
+ modelTextField.widthAnchor.constraint(equalToConstant: 250).isActive = true
+ modelTextField.stringValue = UserDefaults.standard.string(forKey: "OllamaModel") ?? "llama3"
+ addFormRow(title: NSLocalizedString("Model:", comment: ""), control: modelTextField)
+
+ // Language
+ languageTextField.translatesAutoresizingMaskIntoConstraints = false
+ languageTextField.widthAnchor.constraint(equalToConstant: 250).isActive = true
+ languageTextField.stringValue = UserDefaults.standard.string(forKey: "OllamaPreferredLanguage") ?? "Chinese"
+ addFormRow(title: NSLocalizedString("Target Language:", comment: ""), control: languageTextField)
+
+ // Preload Count
+ preloadCountTextField.translatesAutoresizingMaskIntoConstraints = false
+ preloadCountTextField.widthAnchor.constraint(equalToConstant: 100).isActive = true
+ preloadCountTextField.stringValue = String(UserDefaults.standard.integer(forKey: "OllamaPreloadCount"))
+
+ addFormRow(title: NSLocalizedString("Preload Next:", comment: ""), control: preloadCountTextField)
+
+ // Auto-Translate
+ autoTranslateCheckbox.state = UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") ? .on : .off
+
+ let checkboxContainer = NSStackView()
+ checkboxContainer.orientation = .horizontal
+ let spacer = NSView()
+ spacer.translatesAutoresizingMaskIntoConstraints = false
+ spacer.widthAnchor.constraint(equalToConstant: 120 + 8).isActive = true // Label width + spacing
+ checkboxContainer.addArrangedSubview(spacer)
+ checkboxContainer.addArrangedSubview(autoTranslateCheckbox)
+ stackView.addArrangedSubview(checkboxContainer)
+
+ // Targets/Actions
+ baseURLTextField.target = self
+ baseURLTextField.action = #selector(baseURLChanged(_:))
+
+ modelTextField.target = self
+ modelTextField.action = #selector(modelChanged(_:))
+
+ languageTextField.target = self
+ languageTextField.action = #selector(languageChanged(_:))
+
+ preloadCountTextField.target = self
+ preloadCountTextField.action = #selector(preloadCountChanged(_:))
+
+ autoTranslateCheckbox.target = self
+ autoTranslateCheckbox.action = #selector(autoTranslateChanged(_:))
+ }
+
+ @objc private func baseURLChanged(_ sender: NSTextField) {
+ UserDefaults.standard.set(sender.stringValue, forKey: "OllamaBaseURL")
+ }
+
+ @objc private func modelChanged(_ sender: NSTextField) {
+ UserDefaults.standard.set(sender.stringValue, forKey: "OllamaModel")
+ }
+
+ @objc private func languageChanged(_ sender: NSTextField) {
+ UserDefaults.standard.set(sender.stringValue, forKey: "OllamaPreferredLanguage")
+ }
+
+ @objc private func preloadCountChanged(_ sender: NSTextField) {
+ let value = max(0, min(50, sender.integerValue))
+ UserDefaults.standard.set(value, forKey: "OllamaPreloadCount")
+ sender.stringValue = String(value)
+ }
+
+ @objc private func autoTranslateChanged(_ sender: NSButton) {
+ UserDefaults.standard.set(sender.state == .on, forKey: "OllamaAutoTranslate")
+ NotificationCenter.default.post(name: Notification.Name("OllamaAutoTranslateDidChange"), object: nil)
+ }
+}
diff --git a/Modules/RSCore/Sources/RSCore/OllamaClient.swift b/Modules/RSCore/Sources/RSCore/OllamaClient.swift
new file mode 100644
index 000000000..656fe5b4e
--- /dev/null
+++ b/Modules/RSCore/Sources/RSCore/OllamaClient.swift
@@ -0,0 +1,192 @@
+import Foundation
+
+public final class OllamaClient: @unchecked Sendable {
+ public static let shared = OllamaClient()
+
+ private var baseURL: URL {
+ if let urlString = UserDefaults.standard.string(forKey: "OllamaBaseURL"), let url = URL(string: urlString) {
+ if urlString.hasSuffix("/api") || urlString.hasSuffix("/api/") {
+ return url
+ }
+ return url.appendingPathComponent("api")
+ }
+ return URL(string: "http://localhost:11434/api")!
+ }
+
+ private var model: String {
+ return UserDefaults.standard.string(forKey: "OllamaModel") ?? "llama3"
+ }
+
+ private var preferredLanguage: String {
+ return UserDefaults.standard.string(forKey: "OllamaPreferredLanguage") ?? "Chinese"
+ }
+
+ private var translationCache = [String: String]()
+ private var activeTasks = [String: URLSessionDataTask]()
+ private var completionHandlers = [String: [(Result) -> Void]]()
+ private var explicitRequests = Set()
+
+ private let cacheQueue = DispatchQueue(label: "com.netnewswire.ollamacache")
+
+ private lazy var session: URLSession = {
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 300 // 5 minutes to prevent timeout
+ config.timeoutIntervalForResource = 300
+ return URLSession(configuration: config)
+ }()
+
+ public init() {}
+
+ @discardableResult
+ private func generate(prompt: String, completion: @escaping (Result) -> Void) -> URLSessionDataTask? {
+ let url = baseURL.appendingPathComponent("generate")
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let payload: [String: Any] = [
+ "model": model,
+ "prompt": prompt,
+ "stream": false
+ ]
+
+ request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
+
+ let task = session.dataTask(with: request) { data, response, error in
+ if let error = error {
+ completion(.failure(error))
+ return
+ }
+
+ guard let data = data else {
+ let err = NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"])
+ completion(.failure(err))
+ return
+ }
+
+ do {
+ if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ if let responseText = json["response"] as? String {
+ completion(.success(responseText))
+ } else if let errorText = json["error"] as? String {
+ completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: errorText])))
+ } else {
+ let raw = String(data: data, encoding: .utf8) ?? "Unknown"
+ completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API response: \(raw)"])))
+ }
+ } else {
+ let raw = String(data: data, encoding: .utf8) ?? "Unknown"
+ completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format. Raw: \(raw)"])))
+ }
+ } catch {
+ let raw = String(data: data, encoding: .utf8) ?? "Unknown"
+ completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON. Raw: \(raw)"])))
+ }
+ }
+ task.resume()
+ return task
+ }
+
+ public func cachedTranslation(articleID: String) -> String? {
+ var result: String?
+ cacheQueue.sync {
+ result = translationCache[articleID]
+ }
+ return result
+ }
+
+ public func translate(articleID: String, text: String, completion: @escaping (Result) -> Void) {
+ guard !text.isEmpty else {
+ completion(.failure(NSError(domain: "OllamaClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No text available"])))
+ return
+ }
+
+ cacheQueue.async {
+ if let cached = self.translationCache[articleID] {
+ completion(.success(cached))
+ return
+ }
+
+ self.explicitRequests.insert(articleID)
+
+ if self.activeTasks[articleID] != nil {
+ // Task is already running (e.g., from preload), attach completion handler
+ var handlers = self.completionHandlers[articleID] ?? []
+ handlers.append(completion)
+ self.completionHandlers[articleID] = handlers
+ return
+ }
+
+ let prompt = "Translate the following text to \(self.preferredLanguage). ONLY return the translated text and nothing else. Do not add any conversational filler, side notes, explanations, or multiple options. Provide exactly one translation:\n\n\(text)"
+
+ self.completionHandlers[articleID] = [completion]
+
+ let task = self.generate(prompt: prompt) { [weak self] result in
+ self?.cacheQueue.async {
+ self?.activeTasks.removeValue(forKey: articleID)
+ self?.explicitRequests.remove(articleID)
+
+ if case .success(let translation) = result {
+ self?.translationCache[articleID] = translation
+ }
+
+ if let handlers = self?.completionHandlers.removeValue(forKey: articleID) {
+ for handler in handlers {
+ handler(result)
+ }
+ }
+ }
+ }
+
+ if let task = task {
+ self.activeTasks[articleID] = task
+ }
+ }
+ }
+
+ public func preloadTranslations(items: [(id: String, text: String)]) {
+ let desiredIDs = Set(items.map { $0.id })
+
+ cacheQueue.async {
+ // Cancel active preloads that are no longer needed
+ for (id, task) in self.activeTasks {
+ if !desiredIDs.contains(id) && !self.explicitRequests.contains(id) {
+ task.cancel()
+ self.activeTasks.removeValue(forKey: id)
+ self.completionHandlers.removeValue(forKey: id)
+ }
+ }
+
+ // Start new preloads
+ for item in items {
+ let id = item.id
+ let text = item.text
+
+ guard self.translationCache[id] == nil, self.activeTasks[id] == nil else {
+ continue
+ }
+ guard !text.isEmpty else { continue }
+
+ let prompt = "Translate the following text to \(self.preferredLanguage). ONLY return the translated text and nothing else. Do not add any conversational filler, side notes, explanations, or multiple options. Provide exactly one translation:\n\n\(text)"
+
+ let task = self.generate(prompt: prompt) { [weak self] result in
+ self?.cacheQueue.async {
+ self?.activeTasks.removeValue(forKey: id)
+ if case .success(let translation) = result {
+ self?.translationCache[id] = translation
+ }
+ if let handlers = self?.completionHandlers.removeValue(forKey: id) {
+ for handler in handlers {
+ handler(result)
+ }
+ }
+ }
+ }
+
+ if let task = task {
+ self.activeTasks[id] = task
+ }
+ }
+ }
+ }
+}
diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift
index e54d9bcfb..5bc803c28 100644
--- a/iOS/AppDefaults.swift
+++ b/iOS/AppDefaults.swift
@@ -399,7 +399,12 @@ final class AppDefaults: Sendable {
Key.confirmMarkAllAsRead: true,
Key.articleContentJavascriptEnabled: true,
Key.currentThemeName: Self.defaultThemeName,
- Key.splitViewPreferredDisplayMode: UISplitViewController.DisplayMode.oneBesideSecondary.rawValue]
+ Key.splitViewPreferredDisplayMode: UISplitViewController.DisplayMode.oneBesideSecondary.rawValue,
+ Key.ollamaBaseURL: "http://localhost:11434/api",
+ Key.ollamaModel: "llama3",
+ Key.ollamaPreferredLanguage: "Chinese",
+ Key.ollamaPreloadCount: 10,
+ Key.ollamaAutoTranslate: false]
AppDefaults.store.register(defaults: defaults)
}
}
diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift
index 993dffdd8..1f7773ff0 100644
--- a/iOS/Article/WebViewController.swift
+++ b/iOS/Article/WebViewController.swift
@@ -606,11 +606,28 @@ private extension WebViewController {
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
}
+ var bodyHTML = rendering.html
+ var requestTranslation = false
+ var textToTranslate = ""
+
+ if let article = self.article, UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") {
+ textToTranslate = (self.extractedArticle?.content ?? article.body ?? "").strippingHTML(maxCharacters: 4000)
+
+ if !textToTranslate.isEmpty {
+ if let cached = OllamaClient.shared.cachedTranslation(articleID: article.articleID) {
+ bodyHTML += "
\(cached)
"
+ } else {
+ bodyHTML += "
Translating...
"
+ requestTranslation = true
+ }
+ }
+ }
+
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
- "body": rendering.html,
+ "body": bodyHTML,
"windowScrollY": String(windowScrollY)
]
@@ -626,6 +643,40 @@ private extension WebViewController {
WebViewConfiguration.addContentBlockingRules(to: webView)
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
+
+ if requestTranslation {
+ OllamaClient.shared.translate(articleID: self.article!.articleID, text: textToTranslate) { [weak self, articleID = self.article!.articleID] result in
+ DispatchQueue.main.async {
+ guard let self = self, self.article?.articleID == articleID else { return }
+ if case .success(let translated) = result {
+ self.injectTranslation(translated)
+ } else if case .failure(let error) = result {
+ self.injectTranslation("Translation failed: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+ }
+
+ func injectTranslation(_ translatedText: String) {
+ let encoded = translatedText
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+
+ let js = """
+ function updateTranslation() {
+ var translationDiv = document.getElementById('ollama-translation');
+ if (translationDiv) {
+ translationDiv.innerText = \"\(encoded)\";
+ } else {
+ setTimeout(updateTranslation, 100);
+ }
+ }
+ updateTranslation();
+ """
+ webView?.evaluateJavaScript(js)
}
func finalScrollPosition(scrollingUp: Bool) -> CGFloat {
diff --git a/iOS/MainTimeline/MainTimelineModernViewController.swift b/iOS/MainTimeline/MainTimelineModernViewController.swift
index db21f9d8b..1cc89649e 100644
--- a/iOS/MainTimeline/MainTimelineModernViewController.swift
+++ b/iOS/MainTimeline/MainTimelineModernViewController.swift
@@ -504,6 +504,26 @@ extension MainTimelineModernViewController: UICollectionViewDelegate {
becomeFirstResponder()
if let dataSource {
let article = dataSource.itemIdentifier(for: indexPath)
+
+ if UserDefaults.standard.bool(forKey: "OllamaAutoTranslate") {
+ let count = UserDefaults.standard.object(forKey: "OllamaPreloadCount") != nil ? UserDefaults.standard.integer(forKey: "OllamaPreloadCount") : 10
+ if count > 0 {
+ let snapshot = dataSource.snapshot()
+ let articles = snapshot.itemIdentifiers
+ if let article = article, let index = articles.firstIndex(of: article) {
+ let nextIndex = index + 1
+ if nextIndex < articles.count {
+ let limit = min(articles.count, nextIndex + count)
+ let itemsToPreload = articles[nextIndex.. (id: String, text: String)? in
+ let text = (a.body ?? "").strippingHTML(maxCharacters: 4000)
+ return text.isEmpty ? nil : (a.articleID, text)
+ }
+ OllamaClient.shared.preloadTranslations(items: itemsToPreload)
+ }
+ }
+ }
+ }
+
coordinator?.selectArticle(article, animations: [.scroll, .select, .navigation])
}
}