* feat(editor): add setting to turn off spellcheck underlines Add a device-local "Underline spelling mistakes" switch to the editing preferences. The rich-text editor and the mobile plain-text editor now set spellcheck from this preference and update live when it changes. Defaults to enabled, so existing behavior is unchanged. Refs #468 * feat(i18n): add Polish as an in-app locale Add Polish (pl) across clients, mirroring how Japanese was added: - Shared: full pl catalog, locale matching for pl/pl-*, Polish memo templates and factory AI prompts, mobile editor strings, and Polish as an AI translation target and selection-translate chip. - Web: "Polski" in the language picker; the catalog is lazy-loaded in its own i18n-pl chunk and kept out of the PWA precache like ja. - Android: pl locale option, mobile-only strings, web-clip labels. Messages that only had an English branch now use English for every non-Chinese locale, so Japanese no longer falls back to Chinese there. - iOS: .polish UI language (pl-PL), pl argument for every t() call, pl in CFBundleLocalizations and Localizable.xcstrings. - Companion: accepts locale "pl" and detects Polish requests. - Browser extension: _locales/pl messages. - Marketing site: /pl/ pages, hreflang, sitemap, language switcher. - Google Play listing copy for pl-PL (manual publish only). * fix(ios): show the note editor in Polish and Japanese The iOS editor received every interface language except English as Chinese, so with Polish or Japanese selected its toolbar and image gallery controls were labeled in Chinese. Pass Polish and Japanese through to the embedded editor, label its toolbar and the shared image gallery controls in both languages, and rebuild the checked-in EditorBundle. The Android editor uses the same gallery labels. Also translate the last iOS strings that had no Polish text: the follow-system language label, note created/updated times and the current-device marker. Refs #473 * fix(companion): recognize Polish typed without diacritics Requests in Latin letters without Polish diacritics were always treated as English, so with a Polish interface "Napisz plan na jutro" got an English reply. With a Polish interface, such text is now told apart by common English and Polish words; when it is still unclear, the recent conversation and then the interface language decide. Other interface languages keep treating Latin text as English. Refs #473 * chore(ios): regenerate the Xcode project for Polish Run xcodegen so the shipped Info.plist lists Polish in CFBundleLocalizations and the project knows the pl region. The hand-written AiDirectStream.swift entries are replaced by the IDs xcodegen generates. MARKETING_VERSION and CURRENT_PROJECT_VERSION stay in the project file because the release script and Xcode Cloud update them there. Refs #473 * i18n(pl): translate the sidebar shortcut, desktop menus and video scope Add Polish for features that reached main while this branch was open: the show/hide sidebar shortcut label, the desktop application and editing context menus, and the new "ai:generate" MCP scope. The desktop menus switch to Polish for pl and pl-* locales; other locales are unchanged. Refs #473
336 lines
14 KiB
Swift
336 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct RevisionsView: View {
|
|
@Environment(AppEnvironment.self) private var env
|
|
let memoId: String
|
|
var memoTitle: String?
|
|
var isDeleted: Bool = false
|
|
var onRestored: () -> Void
|
|
|
|
@State private var revisions: [MemoRevision] = []
|
|
@State private var selectedId: String?
|
|
@State private var error: String?
|
|
@State private var restoreError: String?
|
|
@State private var isLoading = true
|
|
@State private var isRestoring = false
|
|
@State private var confirmRestore: MemoRevision?
|
|
|
|
private var selected: MemoRevision? {
|
|
if let selectedId, let match = revisions.first(where: { $0.id == selectedId }) {
|
|
return match
|
|
}
|
|
return revisions.first
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
summaryRow
|
|
timelineSection
|
|
previewSection
|
|
if let restoreError {
|
|
Text(restoreError)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(AppTheme.danger)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.padding(.bottom, 24)
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(AppTheme.accent)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(env.preferences.t("版本历史", en: "Version history", pl: "Historia wersji"))
|
|
.font(.system(size: 16, weight: .heavy))
|
|
.foregroundStyle(AppTheme.title)
|
|
Text(displayTitle)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(AppTheme.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
onRestored()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundStyle(AppTheme.title)
|
|
.frame(width: 32, height: 32)
|
|
.background(AppTheme.searchFill)
|
|
.clipShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(env.preferences.t("关闭", en: "Close", pl: "Zamknij"))
|
|
}
|
|
}
|
|
.task { await load() }
|
|
.refreshable { await load() }
|
|
.confirmationDialog(
|
|
env.preferences.t("恢复到这个历史版本", en: "Restore this version", pl: "Przywróć tę wersję"),
|
|
isPresented: Binding(
|
|
get: { confirmRestore != nil },
|
|
set: { if !$0 { confirmRestore = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(env.preferences.t("恢复", en: "Restore", pl: "Przywróć")) {
|
|
if let rev = confirmRestore {
|
|
Task { await restore(rev) }
|
|
}
|
|
}
|
|
Button(env.preferences.t("取消", en: "Cancel", pl: "Anuluj"), role: .cancel) {
|
|
confirmRestore = nil
|
|
}
|
|
} message: {
|
|
Text(
|
|
env.preferences.t(
|
|
"当前内容会被这个历史版本替换,恢复后仍会产生新的历史记录。",
|
|
en: "Current content will be replaced. Restoring creates a new history entry.",
|
|
pl: "Bieżąca treść zostanie zastąpiona. Przywrócenie utworzy nowy wpis w historii."
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Summary + restore (Android revisionSummaryRow)
|
|
|
|
private var summaryRow: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(
|
|
selected.map {
|
|
env.preferences.t("版本 \($0.revision)", en: "Version \($0.revision)", pl: "Wersja \($0.revision)")
|
|
} ?? env.preferences.t("未选择历史版本", en: "No version selected", pl: "Nie wybrano wersji")
|
|
)
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(AppTheme.title)
|
|
Text(
|
|
env.preferences.t(
|
|
"选择历史记录后可预览并恢复。",
|
|
en: "Select a revision to preview and restore.",
|
|
pl: "Wybierz wersję, aby ją wyświetlić i przywrócić."
|
|
)
|
|
)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppTheme.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if let selected {
|
|
Button {
|
|
confirmRestore = selected
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
if isRestoring {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Image(systemName: "arrow.counterclockwise")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
}
|
|
Text(
|
|
isRestoring
|
|
? env.preferences.t("恢复中", en: "Restoring", pl: "Przywracanie")
|
|
: env.preferences.t("恢复该版本", en: "Restore", pl: "Przywróć")
|
|
)
|
|
.font(.system(size: 13, weight: .bold))
|
|
}
|
|
.foregroundStyle(canRestore ? AppTheme.title : AppTheme.muted)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(AppTheme.card)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!canRestore)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(AppTheme.card)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var canRestore: Bool {
|
|
!isRestoring && !isDeleted && selected != nil
|
|
}
|
|
|
|
// MARK: Timeline pills
|
|
|
|
private var timelineSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(env.preferences.t("历史记录", en: "History", pl: "Historia"))
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(AppTheme.meta)
|
|
|
|
if isLoading {
|
|
timelineState {
|
|
ProgressView()
|
|
Text(env.preferences.t("加载中", en: "Loading", pl: "Wczytywanie"))
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppTheme.secondary)
|
|
}
|
|
} else if let error {
|
|
timelineState {
|
|
Text(env.preferences.t("加载失败", en: "Failed to load", pl: "Nie udało się wczytać"))
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundStyle(AppTheme.danger)
|
|
Text(error)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(AppTheme.secondary)
|
|
.multilineTextAlignment(.center)
|
|
Button(env.preferences.t("重试", en: "Retry", pl: "Spróbuj ponownie")) {
|
|
Task { await load() }
|
|
}
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(AppTheme.title)
|
|
}
|
|
} else if revisions.isEmpty {
|
|
timelineState {
|
|
Text(env.preferences.t("暂无历史版本", en: "No revisions yet", pl: "Brak wersji"))
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppTheme.secondary)
|
|
}
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(revisions) { rev in
|
|
revisionPill(rev)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func revisionPill(_ rev: MemoRevision) -> some View {
|
|
let active = selected?.id == rev.id
|
|
return Button {
|
|
selectedId = rev.id
|
|
restoreError = nil
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(env.preferences.t("版本 \(rev.revision)", en: "Version \(rev.revision)", pl: "Wersja \(rev.revision)"))
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(active ? .white : AppTheme.title)
|
|
Text("\(MemoPreviewDate.format(rev.createdAt, locale: env.preferences.resolvedLocale, isEnglish: env.preferences.isEnglish, language: env.preferences.uiLanguage)) · \(Self.formatActor(rev.createdBy))")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(active ? Color.white.opacity(0.9) : AppTheme.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(active ? AppTheme.filterActive : AppTheme.card)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.stroke(active ? AppTheme.filterActive : AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func timelineState<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
|
VStack(spacing: 10) {
|
|
content()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 28)
|
|
.padding(.horizontal, 12)
|
|
.background(AppTheme.card)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// MARK: Preview
|
|
|
|
@ViewBuilder
|
|
private var previewSection: some View {
|
|
if let selected {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(env.preferences.t("预览", en: "Preview", pl: "Podgląd"))
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(AppTheme.meta)
|
|
Text(selected.contentMarkdown.isEmpty ? env.preferences.t("空笔记", en: "Empty note", pl: "Pusta notatka") : selected.contentMarkdown)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppTheme.body)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
.padding(14)
|
|
.background(AppTheme.card)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(AppTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var displayTitle: String {
|
|
let t = memoTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return t.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note", pl: "Notatka bez tytułu") : t
|
|
}
|
|
|
|
/// Android `formatRevisionActor`.
|
|
static func formatActor(_ actor: String) -> String {
|
|
if actor.hasPrefix("user:") { return "user" }
|
|
if actor.hasPrefix("agent:") { return "agent" }
|
|
return actor.isEmpty ? "system" : actor
|
|
}
|
|
|
|
private func load() async {
|
|
isLoading = true
|
|
error = nil
|
|
defer { isLoading = false }
|
|
do {
|
|
let list = try await env.session.client.listMemoRevisions(memoId: memoId)
|
|
revisions = list
|
|
if selectedId == nil || !list.contains(where: { $0.id == selectedId }) {
|
|
selectedId = list.first?.id
|
|
}
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
revisions = []
|
|
}
|
|
}
|
|
|
|
private func restore(_ rev: MemoRevision) async {
|
|
guard let scope = env.session.dataScope else { return }
|
|
isRestoring = true
|
|
restoreError = nil
|
|
defer {
|
|
isRestoring = false
|
|
confirmRestore = nil
|
|
}
|
|
do {
|
|
let memo = try await env.session.client.restoreMemoRevision(memoId: memoId, revisionId: rev.id)
|
|
try env.mirror.upsertMemo(scope: scope, memo: memo)
|
|
await env.runSyncCycle()
|
|
onRestored()
|
|
} catch {
|
|
restoreError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|