Skip to content
Open
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
12 changes: 11 additions & 1 deletion Mac/AppDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 59 additions & 5 deletions Mac/MainWindow/Detail/DetailWebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 += "<hr id='ollama-divider' style='margin: 2em 0;'><div id='ollama-translation'>\(cached)</div>"
} else {
bodyHTML += "<hr id='ollama-divider' style='margin: 2em 0;'><div id='ollama-translation'><i>Translating...</i></div>"
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("<i>Translation failed: \(error.localizedDescription)</i>")
}
}
}
}
}

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? {
Expand Down
15 changes: 15 additions & 0 deletions Mac/MainWindow/Timeline/TimelineViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<limit].compactMap { (a: Article) -> (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)
Expand Down
137 changes: 137 additions & 0 deletions Mac/Preferences/PreferencesWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}()

Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)
}
}
Loading