* 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
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
const workspaceSource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/WorkspaceScreen.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const workspaceEditorsSource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/WorkspaceEditors.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const memoDetailSource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/WorkspaceMemoDetail.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const localTiptapEditorSource = readFileSync(
|
|
new URL("../apps/mobile/src/components/LocalTiptapEditor.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const notesViewSource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/WorkspaceNotesView.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const iosWorkspaceViewSource = readFileSync(
|
|
new URL("../apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const iosMemoDetailSource = readFileSync(
|
|
new URL("../apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const mobilePickersSource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/WorkspacePickers.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const mobileTagsSource = readFileSync(
|
|
new URL("../apps/mobile/src/lib/mobile-tags.ts", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const mobileLocalMirrorSource = readFileSync(
|
|
new URL("../apps/mobile/src/lib/local-mirror.ts", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const iosLocalMirrorSource = readFileSync(
|
|
new URL("../apps/ios/EdgeEver/Data/Database/LocalMirrorRepository.swift", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const mobileDomSource = readFileSync(
|
|
new URL("../apps/mobile/src/lib/mobile-dom.ts", import.meta.url),
|
|
"utf8"
|
|
);
|
|
const appJson = JSON.parse(
|
|
readFileSync(new URL("../apps/mobile/app.json", import.meta.url), "utf8")
|
|
) as {
|
|
expo: {
|
|
ios?: {
|
|
infoPlist?: Record<string, unknown>;
|
|
supportsTablet?: boolean;
|
|
};
|
|
};
|
|
};
|
|
const accountSecuritySource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/AccountSecurityModal.tsx", import.meta.url),
|
|
"utf8"
|
|
);
|
|
|
|
describe("mobile app scope", () => {
|
|
test("keeps workspace administration out of the native app", () => {
|
|
for (const removedCapability of [
|
|
"ApiTokensModal",
|
|
"ResourcesModal",
|
|
"TagsManagerModal",
|
|
"createApiToken",
|
|
"deleteApiToken",
|
|
"mergeMemos",
|
|
]) {
|
|
expect(workspaceSource).not.toContain(removedCapability);
|
|
}
|
|
});
|
|
|
|
test("does not initialize a hidden WebView during workspace startup", () => {
|
|
expect(workspaceSource).not.toContain("EditorRuntimePrewarm");
|
|
expect(workspaceSource).not.toContain("editorRuntimeWarm");
|
|
});
|
|
|
|
test("limits account security to the signed-in user", () => {
|
|
for (const removedCapability of ["createUser", "listUsers", "updateUser"]) {
|
|
expect(accountSecuritySource).not.toContain(removedCapability);
|
|
}
|
|
});
|
|
|
|
test("keeps version history reachable from an active note", () => {
|
|
expect(memoDetailSource).toMatch(
|
|
/\{memo && !memo\.isDeleted \? \(\s*<Pressable\s+accessibilityLabel="版本历史"/
|
|
);
|
|
expect(memoDetailSource).toContain('syncStatus === "conflict"');
|
|
expect(memoDetailSource).toContain("onResolveSyncConflict");
|
|
});
|
|
|
|
test("renders note detail body with the shared read-only TipTap viewer", () => {
|
|
expect(memoDetailSource).toContain('mode={isEditing ? "editor" : "viewer"}');
|
|
expect(memoDetailSource).toContain("LocalTiptapEditor");
|
|
expect(memoDetailSource).not.toContain("react-native-markdown-display");
|
|
});
|
|
|
|
test("opens existing-note editing in place without a blocking getMemo", () => {
|
|
const openRichEditorSource = workspaceSource.slice(
|
|
workspaceSource.indexOf("const openRichEditor ="),
|
|
workspaceSource.indexOf("const memos = useMemo"),
|
|
);
|
|
expect(openRichEditorSource).not.toContain("client.getMemo");
|
|
expect(openRichEditorSource).not.toContain("listMobileSyncQueueItems");
|
|
expect(openRichEditorSource).not.toContain("setSelectedMemoId(null)");
|
|
expect(openRichEditorSource).toContain("setRichEditingSession");
|
|
expect(workspaceSource).not.toContain("return <RichEditorModal");
|
|
expect(memoDetailSource).toContain("useMobileRichEditor");
|
|
expect(localTiptapEditorSource).toContain("editor.setEditable(!isViewer)");
|
|
expect(memoDetailSource).toContain("{visible ? (");
|
|
expect(memoDetailSource).toContain(") : null}");
|
|
expect(memoDetailSource).toContain('accessibilityLabel="返回" accessibilityRole="button" disabled={editor.uploading}');
|
|
});
|
|
|
|
test("carries workspace search into note detail and scrolls active matches", () => {
|
|
expect(workspaceSource).toContain('initialSearchQuery={selectedMemoId ? searchText.trim() : ""}');
|
|
expect(memoDetailSource).toContain("metadataSearchMatchCount + bodySearchMatchCount");
|
|
expect(memoDetailSource).toContain("const retryTimers = [120, 360]");
|
|
expect(localTiptapEditorSource).toContain("createMobileNoteSearchHighlightPlugin");
|
|
expect(localTiptapEditorSource).toContain("scrollEditorPositionIntoView(editor, match.from");
|
|
});
|
|
|
|
test("keeps in-note search on one icon row", () => {
|
|
expect(memoDetailSource).toContain('accessibilityLabel="上一个搜索结果"');
|
|
expect(memoDetailSource).toContain('accessibilityLabel="下一个搜索结果"');
|
|
expect(memoDetailSource).toContain('accessibilityLabel="关闭搜索"');
|
|
expect(memoDetailSource).not.toContain("label=\"上一个搜索结果\"");
|
|
expect(memoDetailSource).not.toContain("label=\"下一个搜索结果\"");
|
|
expect(memoDetailSource).not.toContain("label=\"关闭搜索\"");
|
|
const stylesSource = readFileSync(
|
|
new URL("../apps/mobile/src/screens/workspace-styles.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
expect(stylesSource).toMatch(/noteSearchPanel: \{[\s\S]*?flexDirection: "row"/);
|
|
});
|
|
|
|
test("keeps the Android editor caret visible while the keyboard viewport changes", () => {
|
|
expect(workspaceEditorsSource).toContain("KeyboardAvoidingView");
|
|
expect(workspaceEditorsSource).toContain('enabled={Platform.OS === "android"}');
|
|
expect(localTiptapEditorSource).toContain('visualViewport?.addEventListener("resize", ensureSelectionVisible)');
|
|
expect(localTiptapEditorSource).toContain("--edgeever-keyboard-inset");
|
|
expect(localTiptapEditorSource).toContain("scrollEditorPositionIntoView(editor, editor.state.selection.head)");
|
|
});
|
|
|
|
test("puts specific tag filtering on the visible list chip instead of tagged/untagged toggles", () => {
|
|
expect(notesViewSource).toContain("onOpenTagFilter");
|
|
expect(notesViewSource).toContain('label={selectedTag ? `#${selectedTag}` : "按标签筛选"}');
|
|
expect(notesViewSource).not.toContain('label="有标签"');
|
|
expect(notesViewSource).not.toContain('label="无标签"');
|
|
expect(workspaceSource).toContain("onOpenTagFilter={() => setTagFilterPickerOpen(true)}");
|
|
|
|
expect(iosWorkspaceViewSource).toContain("store.showTagFilterPicker = true");
|
|
expect(iosWorkspaceViewSource).toContain('env.preferences.t("按标签筛选", en: "Filter by tag"');
|
|
expect(iosWorkspaceViewSource).not.toContain('env.preferences.t("有标签", en: "Tagged")');
|
|
expect(iosWorkspaceViewSource).not.toContain('env.preferences.t("无标签", en: "Untagged")');
|
|
});
|
|
|
|
test("applies exact tag matching in the local memo list instead of json_each on the full note blob", () => {
|
|
expect(workspaceSource).toContain("tag: memoView === \"notebook\" ? selectedTag ?? undefined : undefined");
|
|
expect(mobileTagsSource).toContain("filterLocalMemosByExactTag");
|
|
expect(mobileLocalMirrorSource).toContain("filterLocalMemosByExactTag");
|
|
expect(mobileLocalMirrorSource).not.toContain("json_each(mobile_memos.data_json");
|
|
expect(iosLocalMirrorSource).toContain("MobileUI.memoHasExactTag");
|
|
expect(iosLocalMirrorSource).not.toContain("json_each(mobile_memos.data_json");
|
|
});
|
|
|
|
test("keeps Android memo list motion and spring feedback", () => {
|
|
expect(notesViewSource).toContain("FadeInDown.duration(260).springify().damping(18)");
|
|
expect(notesViewSource).toContain("FadeOutUp.duration(220)");
|
|
expect(notesViewSource).toContain("LinearTransition.duration(220)");
|
|
expect(notesViewSource).toContain("pressScale.value = withTiming(0.985");
|
|
expect(notesViewSource).toContain("pressScale.value = withTiming(1");
|
|
});
|
|
|
|
test("hardens DOM/WebView hosts against media capture probes during App Review", () => {
|
|
expect(mobileDomSource).toContain('mediaCapturePermissionGrantType: "deny"');
|
|
expect(mobileDomSource).toContain("mediaPlaybackRequiresUserAction: true");
|
|
expect(workspaceEditorsSource).toContain("SAFE_DOM_WEBVIEW_PROPS");
|
|
expect(memoDetailSource).toContain("SAFE_DOM_WEBVIEW_PROPS");
|
|
});
|
|
|
|
test("reads the latest create and upload state from the hardware-back handler", () => {
|
|
expect(workspaceEditorsSource).toContain("createPendingRef.current || imageOperationRef.current");
|
|
});
|
|
|
|
test("focuses the note body instead of the title when creating a note", () => {
|
|
const createMemoSource = workspaceEditorsSource.slice(
|
|
workspaceEditorsSource.indexOf("export const CreateMemoModal ="),
|
|
workspaceEditorsSource.indexOf("export const RichEditorModal =")
|
|
);
|
|
const titleInput = createMemoSource.match(
|
|
/<TextInput\s+autoCorrect\s+accessibilityLabel="笔记标题"[\s\S]*?\/>/
|
|
)?.[0];
|
|
|
|
expect(createMemoSource).toMatch(/<LocalTiptapEditor\s+autoFocus\s/);
|
|
expect(createMemoSource).toContain("scheduleBodyKeyboard(180, false)");
|
|
expect(titleInput).toBeDefined();
|
|
expect(titleInput).not.toContain("autoFocus");
|
|
expect(createMemoSource).not.toContain("scheduleTitleFocus");
|
|
});
|
|
|
|
test("keeps editor startup recoverable and avoids competing autofocus paths", () => {
|
|
expect(workspaceEditorsSource).toContain("MOBILE_EDITOR_STARTUP_TIMEOUT_MS");
|
|
expect(workspaceEditorsSource).toContain("MobileEditorStartupOverlay");
|
|
expect(workspaceEditorsSource).toContain("key={editorStartup.attempt}");
|
|
expect(localTiptapEditorSource).toContain("autofocus: false");
|
|
expect(localTiptapEditorSource).toContain('import("mermaid/dist/mermaid.min.js")');
|
|
expect(localTiptapEditorSource).toContain('import("beautiful-mermaid")');
|
|
expect(localTiptapEditorSource).toContain('import("html-to-image")');
|
|
expect(localTiptapEditorSource).not.toContain('import "mermaid/dist/mermaid.min.js"');
|
|
});
|
|
|
|
test("lets view-mode change the note notebook without entering the editor", () => {
|
|
expect(memoDetailSource).toContain('setViewerNotebookPickerOpen(true)');
|
|
expect(memoDetailSource).toContain("payload: { notebookId: nextNotebookId }");
|
|
expect(memoDetailSource).toContain("includeAllNotes={false}");
|
|
expect(memoDetailSource).toContain('accessibilityLabel="所在笔记本"');
|
|
expect(memoDetailSource).toContain("handleViewerNotebookSelect");
|
|
expect(mobilePickersSource).toContain("includeAllNotes = true");
|
|
expect(iosMemoDetailSource).toContain("showNotebookPicker = true");
|
|
expect(iosMemoDetailSource).toContain("moveMemoToNotebook");
|
|
expect(iosMemoDetailSource).toContain("EditNotebookPickerSheet");
|
|
expect(iosMemoDetailSource).toContain("notebookAffiliationControl");
|
|
});
|
|
|
|
test("declares iOS privacy strings and full-screen phone-on-iPad presentation", () => {
|
|
const infoPlist = appJson.expo.ios?.infoPlist ?? {};
|
|
expect(appJson.expo.ios?.supportsTablet).toBe(false);
|
|
expect(infoPlist.UIRequiresFullScreen).toBe(true);
|
|
expect(String(infoPlist.NSMicrophoneUsageDescription ?? "")).toMatch(/microphone/i);
|
|
expect(String(infoPlist.NSCameraUsageDescription ?? "")).toMatch(/camera/i);
|
|
});
|
|
});
|