Files
Dariusz Koryto 80dadb3194 feat(i18n): add Polish as an in-app locale (#473)
* 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
2026-10-06 16:18:38 +08:00

372 lines
16 KiB
Swift

import SwiftUI
/// Android `NotebookPickerModal` parity: handle, header, search, All notes, tree with expand/collapse.
struct NotebookPickerSheet: View {
@Environment(AppEnvironment.self) private var env
@Bindable var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var collapsedIds: Set<String> = []
@State private var isCreating = false
@State private var isSaving = false
@State private var newName = ""
@State private var newParentId: String?
@State private var createError: String?
private var parentIds: Set<String> {
NotebookHierarchy.parentIdsWithChildren(from: store.notebooks)
}
private var allBranchesExpanded: Bool {
!parentIds.isEmpty && parentIds.allSatisfy { !collapsedIds.contains($0) }
}
private var activeName: String {
if let id = store.selectedNotebookId,
let name = store.notebooks.first(where: { $0.id == id })?.name
{
return name
}
return env.preferences.t("全部笔记", en: "All notes", pl: "Wszystkie notatki")
}
private var visibleItems: [NotebookTreeItem] {
let tree = NotebookHierarchy.treeItems(from: store.notebooks)
let search = query.trimmingCharacters(in: .whitespacesAndNewlines)
if !search.isEmpty {
let visible = NotebookHierarchy.searchVisibleIds(notebooks: store.notebooks, searchText: search)
return tree.filter { visible.contains($0.id) }
}
return NotebookHierarchy.filterCollapsed(items: tree, collapsedIds: collapsedIds)
}
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(AppTheme.sheetHandle)
.frame(width: 42, height: 4)
.padding(.top, 10)
.padding(.bottom, 8)
// Header
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 2) {
Text(env.preferences.t("切换笔记本", en: "Switch notebook", pl: "Przełącz notatnik"))
.font(.system(size: 15, weight: .heavy))
.foregroundStyle(AppTheme.title)
Text(env.preferences.t("当前:\(activeName)", en: "Current: \(activeName)", pl: "Bieżący: \(activeName)"))
.font(.system(size: 12))
.foregroundStyle(AppTheme.secondary)
}
Spacer(minLength: 8)
Button {
isCreating.toggle()
createError = nil
} label: {
Image(systemName: "plus")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AppTheme.accent)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel(env.preferences.t("新建笔记本", en: "New notebook", ja: "新しいノートブック", pl: "Nowy notatnik"))
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.title)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.frame(minHeight: 56)
.overlay(alignment: .bottom) {
Rectangle().fill(AppTheme.border).frame(height: 1)
}
if isCreating {
VStack(alignment: .leading, spacing: 8) {
TextField(env.preferences.t("笔记本名称", en: "Notebook name", ja: "ノートブック名", pl: "Nazwa notatnika"), text: $newName)
.font(.system(size: 15))
.foregroundStyle(AppTheme.title)
.textInputAutocapitalization(.sentences)
.padding(.horizontal, 12)
.frame(height: 42)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppTheme.border))
HStack(spacing: 8) {
parentChoice(env.preferences.t("顶层笔记本", en: "Top level", ja: "最上位", pl: "Najwyższy poziom"), id: nil)
if let selected = store.selectedNotebookId {
parentChoice(env.preferences.t("位于「\(activeName)」下", en: "Under \(activeName)", ja: "「\(activeName)」の下", pl: "W notatniku „\(activeName)”"), id: selected)
}
}
if let createError {
Text(createError)
.font(.system(size: 12))
.foregroundStyle(.red)
.accessibilityAddTraits(.updatesFrequently)
}
Button {
Task { await createNotebook() }
} label: {
Group {
if isSaving {
ProgressView().tint(.white)
} else {
Text(env.preferences.t("创建笔记本", en: "Create notebook", ja: "ノートブックを作成", pl: "Utwórz notatnik"))
}
}
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(AppTheme.accent)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(isSaving || newName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Search
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.secondary)
TextField(env.preferences.t("搜索笔记本", en: "Search notebooks", pl: "Szukaj notatników"), text: $query)
.font(.system(size: 14))
.foregroundStyle(AppTheme.title)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !query.isEmpty {
Button {
query = ""
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppTheme.secondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.frame(height: 36)
.background(AppTheme.searchFill)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.padding(.horizontal, 8)
.padding(.top, 8)
.padding(.bottom, 8)
// All notes
notebookRow(
title: env.preferences.t("全部笔记", en: "All notes", pl: "Wszystkie notatki"),
selected: store.selectedNotebookId == nil,
depth: 0,
hasChildren: false,
collapsed: false,
onToggle: nil
) {
select(nil)
}
.padding(.bottom, 4)
// Section header
HStack {
Text(
query.trimmingCharacters(in: .whitespaces).isEmpty
? env.preferences.t("笔记本", en: "Notebooks", pl: "Notatniki")
: env.preferences.t("匹配的笔记本", en: "Matching notebooks", pl: "Pasujące notatniki")
)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppTheme.secondary)
Spacer()
if query.trimmingCharacters(in: .whitespaces).isEmpty, !parentIds.isEmpty {
Button {
withAnimation(Motion.chip) {
if allBranchesExpanded {
collapsedIds = parentIds
} else {
collapsedIds = []
}
}
} label: {
Text(
allBranchesExpanded
? env.preferences.t("收起全部", en: "Collapse all", pl: "Zwiń wszystko")
: env.preferences.t("展开全部", en: "Expand all", pl: "Rozwiń wszystko")
)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppTheme.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
}
}
.frame(minHeight: 32)
.padding(.horizontal, 12)
// Tree
ForEach(visibleItems) { item in
let hasChildren = parentIds.contains(item.id)
let isCollapsed = collapsedIds.contains(item.id)
notebookRow(
title: item.name,
selected: store.selectedNotebookId == item.id,
depth: item.depth,
hasChildren: hasChildren && query.trimmingCharacters(in: .whitespaces).isEmpty,
collapsed: isCollapsed,
onToggle: hasChildren && query.trimmingCharacters(in: .whitespaces).isEmpty
? {
withAnimation(Motion.chip) {
if collapsedIds.contains(item.id) {
collapsedIds.remove(item.id)
} else {
collapsedIds.insert(item.id)
}
}
}
: nil
) {
select(item.id)
}
}
if visibleItems.isEmpty {
VStack(spacing: 8) {
Image(systemName: "folder")
.font(.system(size: 28))
.foregroundStyle(AppTheme.muted)
Text(env.preferences.t("没有匹配的笔记本", en: "No matching notebooks", pl: "Brak pasujących notatników"))
.font(.system(size: 13))
.foregroundStyle(AppTheme.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
}
}
.padding(.bottom, 12)
}
}
.background(AppTheme.card)
.presentationDetents(isCreating ? [.large] : [.medium, .large])
.presentationDragIndicator(.hidden)
.onAppear {
isCreating = store.startCreatingNotebook
store.startCreatingNotebook = false
// Match Android: collapse branches not on the path to the active notebook.
let parents = parentIds
if let selected = store.selectedNotebookId {
let keepOpen = NotebookHierarchy.ancestorIds(notebooks: store.notebooks, notebookId: selected)
collapsedIds = parents.filter { !keepOpen.contains($0) }
} else {
collapsedIds = parents
}
query = ""
}
}
private func parentChoice(_ title: String, id: String?) -> some View {
Button {
newParentId = id
} label: {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppTheme.title)
.lineLimit(1)
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(newParentId == id ? AppTheme.accent : AppTheme.border)
)
}
.buttonStyle(.plain)
.accessibilityAddTraits(newParentId == id ? .isSelected : [])
}
private func createNotebook() async {
let name = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !isSaving, !name.isEmpty, name.count <= 80 else { return }
isSaving = true
createError = nil
defer { isSaving = false }
do {
let notebook = try await env.session.client.createNotebook(name: name, parentId: newParentId)
if let scope = env.session.dataScope {
try? env.mirror.saveNotebook(scope: scope, notebook: notebook)
}
store.selectedTag = nil
store.selectedNotebookId = notebook.id
store.reload(env: env)
if !store.notebooks.contains(where: { $0.id == notebook.id }) {
store.notebooks.append(notebook)
}
dismiss()
Task { await env.runSyncCycle() }
} catch {
createError = error.localizedDescription
}
}
private func select(_ id: String?) {
store.selectedTag = nil
store.selectedNotebookId = id
store.reload(env: env)
dismiss()
}
@ViewBuilder
private func notebookRow(
title: String,
selected: Bool,
depth: Int,
hasChildren: Bool,
collapsed: Bool,
onToggle: (() -> Void)?,
onSelect: @escaping () -> Void
) -> some View {
HStack(spacing: 8) {
if hasChildren, let onToggle {
Button(action: onToggle) {
Image(systemName: collapsed ? "chevron.right" : "chevron.down")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.secondary)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
} else {
Color.clear.frame(width: 28, height: 28)
}
Button(action: onSelect) {
HStack {
Text(title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.title)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppTheme.title)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.leading, CGFloat(min(depth * 18, 54)))
.padding(.horizontal, 12)
.frame(minHeight: 48)
.background(selected ? AppTheme.searchFill : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.padding(.horizontal, 8)
}
}