From b3f4dfda2ba5145174acd7991235f387b1d585a1 Mon Sep 17 00:00:00 2001 From: Dariusz Koryto Date: Wed, 14 Oct 2026 14:45:42 +0200 Subject: [PATCH] feat(workspace): localize default notebooks and welcome note at first sign-in (#562) * feat(workspace): localize default notebooks and welcome note at first sign-in A new installation always created its five default notebooks, welcome note and note templates in Chinese, whatever the interface language. Members added by the owner got notebooks and templates in the owner's language instead of their own. The seed data is now localized once, at the first sign-in of the workspace's user, using the UI locale the client sends with the login request (Web and Android send it; other clients fall back to Accept-Language, and unsupported languages to English). Migration 0070 marks the default workspace as waiting for this only on an installation nobody has used yet: no users, no notes beyond the welcome note, no edits to it, no extra notebooks, revisions or resources. Existing installations keep their data unchanged. Member workspaces created by the owner are marked when created. The sign-in localizes notebook names, templates, the welcome note and its search document in one transaction that also clears the mark, and replaces only values that still equal a seeded translation. A failed attempt leaves the mark for the next sign-in to retry, and a second sign-in changes nothing. The demo clears the mark so its bilingual seed stays as it is. Refs #483 * fix(workspace): guard seed inserts and title-only welcome edits during localization - Default template and AI prompt inserts in the first-sign-in batch now require the pending marker, so a delayed concurrent sign-in cannot recreate seeds removed after the faster sign-in finished. - The welcome note is treated as untouched only while it keeps the seeded title, the system actor, the seed content hash, and revision 0, so a title-only edit is no longer overwritten. - Add regression tests for both cases. Refs #483 --- apps/api/src/auth-routes.test.mjs | 38 ++ apps/api/src/auth-routes.ts | 7 +- apps/api/src/user-routes.ts | 14 +- apps/api/src/workspace-provisioning.test.mjs | 3 +- apps/api/src/workspace-provisioning.ts | 194 ++++++++- .../src/workspace-seed-localization.test.mjs | 367 ++++++++++++++++++ apps/mobile/src/lib/session.tsx | 5 +- apps/mobile/src/screens/LoginScreen.tsx | 2 +- apps/web/src/app/App.tsx | 8 +- apps/web/src/lib/api.ts | 2 +- .../0070_workspace_seed_localization.sql | 32 ++ packages/shared/package.json | 1 + .../shared/src/default-workspace-seeds.ts | 130 +++++++ packages/shared/src/schemas.ts | 3 + 14 files changed, 779 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/workspace-seed-localization.test.mjs create mode 100644 migrations/0070_workspace_seed_localization.sql create mode 100644 packages/shared/src/default-workspace-seeds.ts diff --git a/apps/api/src/auth-routes.test.mjs b/apps/api/src/auth-routes.test.mjs index ff3f67af..55f49259 100644 --- a/apps/api/src/auth-routes.test.mjs +++ b/apps/api/src/auth-routes.test.mjs @@ -7,6 +7,7 @@ import { registerAuthRoutes } from "./auth-routes.ts"; import { authenticateRequest } from "./auth-service.ts"; import { sha256 } from "./hash-utils.ts"; import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts"; +import { ensureUserWorkspace } from "./workspace-provisioning.ts"; const interactiveAuth = { kind: "user", @@ -198,3 +199,40 @@ describe("auth route contracts", () => { } }); }); + +describe("first sign-in seed localization", () => { + const signIn = async ({ demo = false, body = {}, headers = {} } = {}) => { + const sqlite = new Database(":memory:"); + for (const file of globSync("migrations/*.sql").sort()) sqlite.exec(readFileSync(file, "utf8")); + sqlite.exec("INSERT INTO users (id, username, password_hash, display_name) VALUES ('usr_owner', 'admin', 'unused', 'admin')"); + const app = createApp({ + ensureUserWorkspace: (...args) => ensureUserWorkspace(...args), + isDemoEnvironment: () => demo, + verifyLogin: async () => ({ id: "usr_owner", username: "admin", display_name: "admin", is_disabled: 0 }), + }); + const response = await app.request("/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ username: "admin", password: "secret", ...body }), + }, { storage: createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-auth-routes-unused") }); + const inbox = sqlite.query("SELECT name FROM notebooks WHERE workspace_id = 'ws_default' AND slug = 'inbox'").get(); + const pending = sqlite.query("SELECT COUNT(*) AS count FROM workspace_seed_localizations").get(); + sqlite.close(); + return { status: response.status, inbox: inbox.name, pending: pending.count }; + }; + + test("prefers the client's resolved UI locale over Accept-Language", async () => { + expect(await signIn({ body: { locale: "pl" }, headers: { "Accept-Language": "ja-JP,ja;q=0.9" } })) + .toEqual({ status: 200, inbox: "Skrzynka", pending: 0 }); + }); + + test("falls back to Accept-Language for clients that do not send a locale", async () => { + expect(await signIn({ headers: { "Accept-Language": "ja-JP,ja;q=0.9" } })) + .toEqual({ status: 200, inbox: "受信トレイ", pending: 0 }); + }); + + test("keeps the demo's own seed in the visitor's language", async () => { + expect(await signIn({ demo: true, body: { locale: "en-US" } })) + .toEqual({ status: 200, inbox: "等待分类", pending: 0 }); + }); +}); diff --git a/apps/api/src/auth-routes.ts b/apps/api/src/auth-routes.ts index cbdb9937..5c33248e 100644 --- a/apps/api/src/auth-routes.ts +++ b/apps/api/src/auth-routes.ts @@ -274,11 +274,16 @@ export const registerAuthRoutes = ( } await clearLoginAttempts(context.env.storage.db, loginAttemptKeys); + if (dependencies.isDemoEnvironment(context.env)) { + // The demo keeps its own bilingual seed; a visitor's sign-in must not + // localize the shared notebooks into that visitor's language. + await context.env.storage.db.prepare(`DELETE FROM workspace_seed_localizations`).run(); + } const workspace = await dependencies.ensureUserWorkspace( context.env.storage.db, user.id, user.username, - context.req.header("accept-language"), + input.locale ?? context.req.header("accept-language"), ); const session = await dependencies.createSession(context, user, input.deviceId); dependencies.setSessionCookie(context, session.token, session.maxAge); diff --git a/apps/api/src/user-routes.ts b/apps/api/src/user-routes.ts index 9c776b99..b1214795 100644 --- a/apps/api/src/user-routes.ts +++ b/apps/api/src/user-routes.ts @@ -12,7 +12,7 @@ import { mapResourceStorageSummary, type ResourceStatsRow } from "./resource-ser import type { DatabaseAdapter } from "./storage-contract"; import { createDefaultNotebookRows, - createWorkspaceDefaultSeedStatements, + markWorkspaceSeedLocalizationPendingStatement, } from "./workspace-provisioning"; export type InstanceUserRow = { @@ -119,7 +119,10 @@ export const registerUserRoutes = ( const workspaceId = createId("ws"); const now = isoNow(); const passwordHash = await hashPassword(input.password); - const notebooks = createDefaultNotebookRows(workspaceId); + // The owner's language is not the member's. Notebooks start in the owner's + // locale and, together with the default templates, are localized at the + // member's first sign-in. + const notebooks = createDefaultNotebookRows(workspaceId, context.req.header("accept-language")); const statements = [ context.env.storage.db.prepare( `INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at) @@ -135,12 +138,7 @@ export const registerUserRoutes = ( `INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, 'notebook', ?, ?, ?, ?)`, ).bind(notebook.id, workspaceId, notebook.name, notebook.slug, notebook.color, notebook.sortOrder, now, now)), - ...createWorkspaceDefaultSeedStatements( - context.env.storage.db, - workspaceId, - now, - context.req.header("accept-language"), - ), + markWorkspaceSeedLocalizationPendingStatement(context.env.storage.db, workspaceId, now), auditStatement(context.env.storage.db, "user", context.get("auth").actorId, "user.create", "user", userId, { username: input.username, }), diff --git a/apps/api/src/workspace-provisioning.test.mjs b/apps/api/src/workspace-provisioning.test.mjs index d4ebae0f..6240eaa1 100644 --- a/apps/api/src/workspace-provisioning.test.mjs +++ b/apps/api/src/workspace-provisioning.test.mjs @@ -48,8 +48,9 @@ describe("workspace provisioning", () => { role: "member", }); expect(batchCount).toBe(0); - expect(calls).toHaveLength(1); + expect(calls).toHaveLength(2); expect(calls[0].sql).toContain("FROM workspace_members WHERE user_id"); + expect(calls[1].sql).toContain("FROM workspace_seed_localizations"); }); test("builds editable localized template and AI prompt statements for new workspaces", () => { diff --git a/apps/api/src/workspace-provisioning.ts b/apps/api/src/workspace-provisioning.ts index 08faf26d..ddb0c458 100644 --- a/apps/api/src/workspace-provisioning.ts +++ b/apps/api/src/workspace-provisioning.ts @@ -2,12 +2,21 @@ import { DEFAULT_AI_PROMPT_SEEDS, defaultAiPromptId, } from "@edgeever/shared/ai-prompt-seeds"; +import { + DEFAULT_NOTEBOOK_SEEDS, + WELCOME_MEMO_ID, + WELCOME_MEMO_SEED_CONTENT_HASH, + WELCOME_MEMO_SEED_TITLE, + buildWelcomeMemoContent, + localizeDefaultNotebookName, +} from "@edgeever/shared/default-workspace-seeds"; import { DEFAULT_MEMO_TEMPLATE_SEEDS, defaultMemoTemplateId, localizeMemoTemplateSeed, } from "@edgeever/shared/memo-template-seeds"; import { createId, isoNow } from "./entity-utils"; +import { sha256 } from "./hash-utils"; import type { DatabaseAdapter, PreparedStatementAdapter, @@ -39,31 +48,39 @@ export type DefaultNotebookRow = { sortOrder: number; }; -export const createDefaultNotebookRows = (workspaceId: string): DefaultNotebookRow[] => [ - { id: workspaceInboxId(workspaceId), name: "等待分类", slug: "inbox", color: "#0f766e", sortOrder: 10 }, - { id: `${workspaceId}_projects`, name: "工作项目", slug: "work-projects", color: "#2563eb", sortOrder: 20 }, - { id: `${workspaceId}_learning`, name: "学习资料", slug: "learning-resources", color: "#7c3aed", sortOrder: 30 }, - { id: `${workspaceId}_creative`, name: "灵感创作", slug: "creative-ideas", color: "#db2777", sortOrder: 40 }, - { id: `${workspaceId}_personal`, name: "生活个人", slug: "personal-life", color: "#ea580c", sortOrder: 50 }, -]; +export const createDefaultNotebookRows = ( + workspaceId: string, + locale?: string | null, +): DefaultNotebookRow[] => DEFAULT_NOTEBOOK_SEEDS.map((seed) => ({ + id: seed.key === "inbox" ? workspaceInboxId(workspaceId) : `${workspaceId}_${seed.key}`, + name: localizeDefaultNotebookName(seed, locale), + slug: seed.slug, + color: seed.color, + sortOrder: seed.sortOrder, +})); // Template use and editing derive the TipTap document from content_markdown. // Keeping this initial value static avoids loading the Markdown parser merely // to provision a workspace. const EMPTY_TEMPLATE_CONTENT_JSON = JSON.stringify({ type: "doc", content: [] }); +// A condition every seed insert must meet, so a delayed batch cannot recreate +// a seed after the step that owned it has finished. +type SeedInsertGuard = { sql: string; bindings: unknown[] }; + export const createWorkspaceDefaultSeedStatements = ( db: DatabaseAdapter, workspaceId: string, now = isoNow(), locale?: string | null, + guard: SeedInsertGuard = { sql: "1", bindings: [] }, ): PreparedStatementAdapter[] => [ ...DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => { const localized = localizeMemoTemplateSeed(seed, locale); return db.prepare( `INSERT OR IGNORE INTO memo_templates ( id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE ${guard.sql}`, ).bind( defaultMemoTemplateId(workspaceId, seed.key), workspaceId, @@ -75,6 +92,7 @@ export const createWorkspaceDefaultSeedStatements = ( JSON.stringify(["template", seed.tag]), now, now, + ...guard.bindings, ); }), ...DEFAULT_AI_PROMPT_SEEDS.map((seed) => db.prepare( @@ -83,7 +101,7 @@ export const createWorkspaceDefaultSeedStatements = ( name, description, instruction, name_customized, description_customized, instruction_customized, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?)`, + ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ? WHERE ${guard.sql}`, ).bind( defaultAiPromptId(workspaceId, seed.key), workspaceId, @@ -96,9 +114,160 @@ export const createWorkspaceDefaultSeedStatements = ( seed.instruction, now, now, + ...guard.bindings, )), ]; +// Workspaces created before their user has signed in (a fresh installation's +// default workspace, or a member added by the owner) wait for that user's +// first sign-in, when the UI locale is known. +export const markWorkspaceSeedLocalizationPendingStatement = ( + db: DatabaseAdapter, + workspaceId: string, + now = isoNow(), +): PreparedStatementAdapter => db.prepare( + `INSERT OR IGNORE INTO workspace_seed_localizations (workspace_id, created_at) VALUES (?, ?)`, +).bind(workspaceId, now); + +const SEED_LOCALIZATION_PENDING = `EXISTS ( + SELECT 1 FROM workspace_seed_localizations WHERE workspace_id = ? +)`; + +// Every update re-checks the pending row and the last statement removes it, so +// the batch runs once even when two sign-ins race, and a failed batch leaves +// the row in place for the next sign-in to retry. Only names and content that +// still match a seeded value are replaced. +export const createWorkspaceSeedLocalizationStatements = async ( + db: DatabaseAdapter, + workspaceId: string, + locale: string | null | undefined, + now = isoNow(), +): Promise => { + const welcome = buildWelcomeMemoContent(locale); + const welcomeContentHash = await sha256(welcome.contentMarkdown + welcome.contentJson); + // The memo row must still carry the seeded title and the system actor, and + // the content must still be the seeded revision. The content is replaced + // before the memo row, so the memo row's guard looks for that replacement. + const welcomeGuard = (contentHash: string, revision: number) => ({ + sql: `${SEED_LOCALIZATION_PENDING} + AND EXISTS ( + SELECT 1 FROM memos + WHERE id = ? AND workspace_id = ? AND is_deleted = 0 + AND title = ? AND updated_by = 'system' + ) + AND EXISTS ( + SELECT 1 FROM memo_contents + WHERE memo_id = ? AND content_hash = ? AND revision = ? + )`, + bindings: [ + workspaceId, + WELCOME_MEMO_ID, + workspaceId, + WELCOME_MEMO_SEED_TITLE, + WELCOME_MEMO_ID, + contentHash, + revision, + ], + }); + const welcomeIsUntouched = welcomeGuard(WELCOME_MEMO_SEED_CONTENT_HASH, 0); + const welcomeContentWasLocalized = welcomeGuard(welcomeContentHash, 1); + + return [ + ...createWorkspaceDefaultSeedStatements(db, workspaceId, now, locale, { + sql: SEED_LOCALIZATION_PENDING, + bindings: [workspaceId], + }), + // Templates that already exist (the default workspace's come from the + // installation migrations) are replaced only while they still equal one + // of the seeded translations. + ...DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => { + const seeded = Object.values(seed.translations); + const localized = localizeMemoTemplateSeed(seed, locale); + const placeholders = seeded.map(() => "?").join(", "); + return db.prepare( + `UPDATE memo_templates + SET name = ?, description = ?, title = ?, content_markdown = ?, updated_at = ? + WHERE id = ? AND workspace_id = ? + AND name IN (${placeholders}) + AND title IN (${placeholders}) + AND description IN (${placeholders}) + AND content_markdown IN (${placeholders}) + AND name <> ? + AND ${SEED_LOCALIZATION_PENDING}`, + ).bind( + localized.title, + localized.description, + localized.title, + localized.contentMarkdown, + now, + defaultMemoTemplateId(workspaceId, seed.key), + workspaceId, + ...seeded.map((translation) => translation.title), + ...seeded.map((translation) => translation.title), + ...seeded.map((translation) => translation.description), + ...seeded.map((translation) => translation.contentMarkdown), + localized.title, + workspaceId, + ); + }), + ...DEFAULT_NOTEBOOK_SEEDS.map((seed) => { + const seededNames = Object.values(seed.names); + const name = localizeDefaultNotebookName(seed, locale); + return db.prepare( + `UPDATE notebooks + SET name = ?, updated_at = ? + WHERE workspace_id = ? AND slug = ? AND parent_id IS NULL AND is_deleted = 0 + AND name IN (${seededNames.map(() => "?").join(", ")}) + AND name <> ? + AND ${SEED_LOCALIZATION_PENDING}`, + ).bind(name, now, workspaceId, seed.slug, ...seededNames, name, workspaceId); + }), + db.prepare( + `INSERT INTO memo_search_documents (memo_id, title, content_text, tags) + SELECT ?, ?, ?, 'edgeever welcome' + WHERE ${welcomeIsUntouched.sql} + ON CONFLICT(memo_id) DO UPDATE SET + title = excluded.title, + content_text = excluded.content_text`, + ).bind(WELCOME_MEMO_ID, welcome.title, welcome.contentText, ...welcomeIsUntouched.bindings), + db.prepare( + `UPDATE memo_contents + SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?, + revision = revision + 1, updated_at = ? + WHERE memo_id = ? AND ${welcomeIsUntouched.sql}`, + ).bind( + welcome.contentJson, + welcome.contentMarkdown, + welcome.contentText, + welcomeContentHash, + now, + WELCOME_MEMO_ID, + ...welcomeIsUntouched.bindings, + ), + db.prepare( + `UPDATE memos + SET title = ?, excerpt = ?, updated_at = ? + WHERE id = ? AND workspace_id = ? AND ${welcomeContentWasLocalized.sql}`, + ).bind(welcome.title, welcome.excerpt, now, WELCOME_MEMO_ID, workspaceId, ...welcomeContentWasLocalized.bindings), + db.prepare( + `DELETE FROM workspace_seed_localizations WHERE workspace_id = ?`, + ).bind(workspaceId), + ]; +}; + +const localizePendingWorkspaceSeeds = async ( + db: DatabaseAdapter, + workspaceId: string, + locale: string | null | undefined, +): Promise => { + const pending = await db.prepare( + `SELECT workspace_id FROM workspace_seed_localizations WHERE workspace_id = ?`, + ).bind(workspaceId).first<{ workspace_id: string }>(); + if (!pending) return false; + await db.batch(await createWorkspaceSeedLocalizationStatements(db, workspaceId, locale)); + return true; +}; + export const ensureUserWorkspace = async ( db: DatabaseAdapter, userId: string, @@ -109,6 +278,7 @@ export const ensureUserWorkspace = async ( `SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`, ).bind(userId).first<{ workspace_id: string; role: "owner" | "member" }>(); if (existing) { + await localizePendingWorkspaceSeeds(db, existing.workspace_id, locale); return { workspaceId: existing.workspace_id, role: existing.role }; } @@ -123,14 +293,16 @@ export const ensureUserWorkspace = async ( `SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`, ).bind(userId).first<{ workspace_id: string; role: "owner" | "member" }>(); if (claimed) { - await db.batch(createWorkspaceDefaultSeedStatements(db, claimed.workspace_id, isoNow(), locale)); + if (!await localizePendingWorkspaceSeeds(db, claimed.workspace_id, locale)) { + await db.batch(createWorkspaceDefaultSeedStatements(db, claimed.workspace_id, isoNow(), locale)); + } return { workspaceId: claimed.workspace_id, role: claimed.role }; } } const workspaceId = createId("ws"); const now = isoNow(); - const notebooks = createDefaultNotebookRows(workspaceId); + const notebooks = createDefaultNotebookRows(workspaceId, locale); await db.batch([ db.prepare( `INSERT INTO workspaces (id, name, is_personal, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`, diff --git a/apps/api/src/workspace-seed-localization.test.mjs b/apps/api/src/workspace-seed-localization.test.mjs new file mode 100644 index 00000000..bc934e8c --- /dev/null +++ b/apps/api/src/workspace-seed-localization.test.mjs @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { globSync, readFileSync } from "node:fs"; +import { WELCOME_MEMO_SEED_CONTENT_HASH } from "@edgeever/shared/default-workspace-seeds"; +import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts"; +import { updateMemoRecord } from "./memo-service.ts"; +import { + DEFAULT_WORKSPACE_ID, + createDefaultNotebookRows, + ensureUserWorkspace, + markWorkspaceSeedLocalizationPendingStatement, +} from "./workspace-provisioning.ts"; + +const LOCALIZATION_MIGRATION = "0070_workspace_seed_localization.sql"; +const migrationFiles = globSync("migrations/*.sql").sort(); +const opened = []; +afterEach(() => opened.splice(0).forEach((sqlite) => sqlite.close())); + +const migrate = (sqlite, filter = () => true) => { + for (const file of migrationFiles.filter(filter)) sqlite.exec(readFileSync(file, "utf8")); +}; + +const openDatabase = () => { + const sqlite = new Database(":memory:"); + opened.push(sqlite); + return { sqlite, db: createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-seed-localization-unused").db }; +}; + +const freshInstallation = () => { + const database = openDatabase(); + migrate(database.sqlite); + return database; +}; + +// An installation that ran every migration before this change, then upgraded. +const upgradedInstallation = (prepare) => { + const database = openDatabase(); + migrate(database.sqlite, (file) => !file.endsWith(LOCALIZATION_MIGRATION)); + prepare(database.sqlite); + database.sqlite.exec(readFileSync(`migrations/${LOCALIZATION_MIGRATION}`, "utf8")); + return database; +}; + +const addUser = (sqlite, id, username = id) => sqlite + .query("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, 'unused', ?)") + .run(id, username, username); + +const notebookNames = (sqlite, workspaceId = DEFAULT_WORKSPACE_ID) => Object.fromEntries( + sqlite.query("SELECT slug, name FROM notebooks WHERE workspace_id = ? AND is_deleted = 0 ORDER BY sort_order") + .all(workspaceId) + .map((row) => [row.slug, row.name]), +); + +const welcome = (sqlite) => sqlite.query( + `SELECT m.title, m.excerpt, c.content_markdown, c.content_text, c.content_json, c.content_hash, c.revision, + s.title AS search_title, s.content_text AS search_text + FROM memos m + JOIN memo_contents c ON c.memo_id = m.id + LEFT JOIN memo_search_documents s ON s.memo_id = m.id + WHERE m.id = 'memo_welcome'`, +).get(); + +const pendingWorkspaces = (sqlite) => sqlite + .query("SELECT workspace_id FROM workspace_seed_localizations ORDER BY workspace_id") + .all() + .map((row) => row.workspace_id); + +const templateNames = (sqlite, workspaceId) => sqlite + .query("SELECT name FROM memo_templates WHERE workspace_id = ? ORDER BY id") + .all(workspaceId) + .map((row) => row.name); + +const searchMatches = (sqlite, query) => sqlite + .query("SELECT memo_id FROM memos_fts WHERE memos_fts MATCH ?") + .all(query) + .map((row) => row.memo_id); + +const CHINESE_NOTEBOOKS = { + inbox: "等待分类", + "work-projects": "工作项目", + "learning-resources": "学习资料", + "creative-ideas": "灵感创作", + "personal-life": "生活个人", +}; + +describe("fresh installation", () => { + test("waits for the first sign-in to localize the default workspace", () => { + const { sqlite } = freshInstallation(); + + expect(pendingWorkspaces(sqlite)).toEqual([DEFAULT_WORKSPACE_ID]); + expect(notebookNames(sqlite)).toEqual(CHINESE_NOTEBOOKS); + }); + + test("localizes notebooks, the welcome note, its search data, and templates to the owner's UI locale", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + + expect(await ensureUserWorkspace(db, "usr_owner", "admin", "pl")).toEqual({ + workspaceId: DEFAULT_WORKSPACE_ID, + role: "owner", + }); + + expect(notebookNames(sqlite)).toEqual({ + inbox: "Skrzynka", + "work-projects": "Projekty zawodowe", + "learning-resources": "Materiały do nauki", + "creative-ideas": "Pomysły i twórczość", + "personal-life": "Życie prywatne", + }); + const note = welcome(sqlite); + expect(note.title).toBe("Witaj w EdgeEver"); + expect(note.search_title).toBe("Witaj w EdgeEver"); + expect(note.excerpt).toStartWith("To Twoja pierwsza notatka"); + expect(note.content_markdown).toStartWith("## Witaj w EdgeEver\n\nTo Twoja pierwsza notatka"); + expect(note.search_text).toBe(note.content_text); + expect(JSON.parse(note.content_json).content[0]).toEqual({ + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Witaj w EdgeEver" }], + }); + expect(note.content_hash).not.toBe(WELCOME_MEMO_SEED_CONTENT_HASH); + expect(note.revision).toBe(1); + expect(searchMatches(sqlite, "Witaj")).toEqual(["memo_welcome"]); + expect(templateNames(sqlite, DEFAULT_WORKSPACE_ID)).not.toContain("灵感速记"); + expect(searchMatches(sqlite, "Skrzynka")).toEqual([]); + expect(templateNames(sqlite, DEFAULT_WORKSPACE_ID)).toContain("Szybka notatka"); + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); + + test("runs once: a later sign-in in another language changes nothing", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + + await ensureUserWorkspace(db, "usr_owner", "admin", "en-US"); + const afterFirst = { notebooks: notebookNames(sqlite), note: welcome(sqlite) }; + await ensureUserWorkspace(db, "usr_owner", "admin", "ja"); + + expect(afterFirst.notebooks.inbox).toBe("Inbox"); + expect(afterFirst.note.title).toBe("Welcome to EdgeEver"); + expect({ notebooks: notebookNames(sqlite), note: welcome(sqlite) }).toEqual(afterFirst); + }); + + test("retries on the next sign-in when the first attempt fails after claiming the workspace", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + const batch = db.batch.bind(db); + db.batch = async () => { + throw new Error("database unavailable"); + }; + + await expect(ensureUserWorkspace(db, "usr_owner", "admin", "ja")).rejects.toThrow("database unavailable"); + expect(sqlite.query("SELECT role FROM workspace_members WHERE user_id = 'usr_owner'").get()) + .toEqual({ role: "owner" }); + expect(pendingWorkspaces(sqlite)).toEqual([DEFAULT_WORKSPACE_ID]); + expect(notebookNames(sqlite)).toEqual(CHINESE_NOTEBOOKS); + + db.batch = batch; + await ensureUserWorkspace(db, "usr_owner", "admin", "ja"); + + expect(notebookNames(sqlite).inbox).toBe("受信トレイ"); + expect(welcome(sqlite).title).toBe("EdgeEver へようこそ"); + expect(templateNames(sqlite, DEFAULT_WORKSPACE_ID)).toContain("ひらめきメモ"); + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); + + test("keeps notebook names and welcome content that no longer match the seed", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + sqlite.exec(` + UPDATE notebooks SET name = 'My projects' WHERE slug = 'work-projects'; + UPDATE memo_contents SET content_hash = 'edited' WHERE memo_id = 'memo_welcome'; + UPDATE memo_templates SET content_markdown = '## Mine' WHERE id = 'ws_default_template_meeting'; + `); + const before = welcome(sqlite); + + await ensureUserWorkspace(db, "usr_owner", "admin", "en-US"); + + expect(notebookNames(sqlite)).toMatchObject({ inbox: "Inbox", "work-projects": "My projects" }); + expect(welcome(sqlite)).toEqual(before); + expect(templateNames(sqlite, DEFAULT_WORKSPACE_ID)).toEqual(expect.arrayContaining(["会议纪要", "Quick Spark"])); + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); + + test("a delayed concurrent first sign-in does not restore seeds removed after the faster one finished", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + const batch = db.batch.bind(db); + let reachedBatch; + const delayedReachedBatch = new Promise((resolve) => { reachedBatch = resolve; }); + let releaseBatch; + const delayedRelease = new Promise((resolve) => { releaseBatch = resolve; }); + let delayed = true; + db.batch = async (statements) => { + if (delayed) { + delayed = false; + reachedBatch(); + await delayedRelease; + } + return batch(statements); + }; + + // The slow sign-in has seen the pending row and holds its batch. + const slow = ensureUserWorkspace(db, "usr_owner", "admin", "ja"); + await delayedReachedBatch; + await ensureUserWorkspace(db, "usr_owner", "admin", "en-US"); + sqlite.exec(` + DELETE FROM memo_templates WHERE workspace_id = 'ws_default' AND name = 'Quick Spark'; + DELETE FROM ai_prompt_templates WHERE workspace_id = 'ws_default'; + `); + const afterFast = { + notebooks: notebookNames(sqlite), + note: welcome(sqlite), + templates: templateNames(sqlite, DEFAULT_WORKSPACE_ID), + }; + releaseBatch(); + await slow; + + expect(afterFast.notebooks.inbox).toBe("Inbox"); + expect(afterFast.templates).not.toContain("Quick Spark"); + expect({ + notebooks: notebookNames(sqlite), + note: welcome(sqlite), + templates: templateNames(sqlite, DEFAULT_WORKSPACE_ID), + }).toEqual(afterFast); + expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_prompt_templates WHERE workspace_id = 'ws_default'").get()) + .toEqual({ count: 0 }); + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); + + test("keeps a welcome note whose title alone was edited before the first sign-in localized it", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + const edited = await updateMemoRecord( + db, + DEFAULT_WORKSPACE_ID, + "memo_welcome", + { title: "My first note" }, + { actorType: "user", actorId: "usr_owner" }, + "admin", + ); + expect(edited.error).toBeUndefined(); + const before = welcome(sqlite); + expect(before.content_hash).toBe(WELCOME_MEMO_SEED_CONTENT_HASH); + expect(pendingWorkspaces(sqlite)).toEqual([DEFAULT_WORKSPACE_ID]); + + await ensureUserWorkspace(db, "usr_owner", "admin", "en-US"); + + expect(welcome(sqlite)).toEqual(before); + expect(before.title).toBe("My first note"); + expect(notebookNames(sqlite).inbox).toBe("Inbox"); + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); + + test("falls back to English for an unsupported language", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + + await ensureUserWorkspace(db, "usr_owner", "admin", "fr-FR,fr;q=0.9"); + + expect(notebookNames(sqlite).inbox).toBe("Inbox"); + expect(welcome(sqlite).title).toBe("Welcome to EdgeEver"); + }); + + test("keeps the Chinese seed when the client sends no language", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + + await ensureUserWorkspace(db, "usr_owner", "admin", undefined); + + expect(notebookNames(sqlite)).toEqual(CHINESE_NOTEBOOKS); + expect(welcome(sqlite).title).toBe("欢迎来到 EdgeEver"); + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); +}); + +describe("existing installation", () => { + test("keeps the default workspace unchanged after upgrading", async () => { + const { sqlite, db } = upgradedInstallation((database) => { + addUser(database, "usr_owner", "admin"); + database.exec("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws_default', 'usr_owner', 'owner')"); + }); + const before = welcome(sqlite); + + expect(pendingWorkspaces(sqlite)).toEqual([]); + await ensureUserWorkspace(db, "usr_owner", "admin", "pl"); + + expect(notebookNames(sqlite)).toEqual(CHINESE_NOTEBOOKS); + expect(welcome(sqlite)).toEqual(before); + }); + + test("treats an unclaimed workspace that already holds notes as existing data", async () => { + const { sqlite, db } = upgradedInstallation((database) => { + database.exec(` + INSERT INTO memos (id, notebook_id, title, excerpt, tags_json, created_by, updated_by) + VALUES ('memo_kept', 'nb_projects', 'Kept', '', '[]', 'system', 'system'); + `); + }); + addUser(sqlite, "usr_owner", "admin"); + + expect(pendingWorkspaces(sqlite)).toEqual([]); + await ensureUserWorkspace(db, "usr_owner", "admin", "pl"); + + expect(notebookNames(sqlite)).toEqual(CHINESE_NOTEBOOKS); + expect(welcome(sqlite).title).toBe("欢迎来到 EdgeEver"); + }); + + test("treats an installation that already has users as existing data", () => { + const { sqlite } = upgradedInstallation((database) => addUser(database, "usr_previous")); + + expect(pendingWorkspaces(sqlite)).toEqual([]); + }); +}); + +describe("member workspaces", () => { + // Mirrors POST /api/v1/users: the owner creates the workspace in the + // owner's language, before the member has ever signed in. + const createMember = async (sqlite, db, ownerLocale) => { + addUser(sqlite, "usr_member", "member"); + sqlite.exec(` + INSERT INTO workspaces (id, name, is_personal) VALUES ('ws_member', 'Member', 1); + INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws_member', 'usr_member', 'member'); + `); + await db.batch([ + ...createDefaultNotebookRows("ws_member", ownerLocale).map((notebook) => db.prepare( + `INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order) + VALUES (?, 'ws_member', NULL, ?, ?, 'notebook', ?, ?)`, + ).bind(notebook.id, notebook.name, notebook.slug, notebook.color, notebook.sortOrder)), + markWorkspaceSeedLocalizationPendingStatement(db, "ws_member"), + ]); + }; + + test("uses the member's language, not the owner's", async () => { + const { sqlite, db } = freshInstallation(); + await createMember(sqlite, db, "en-US"); + expect(notebookNames(sqlite, "ws_member").inbox).toBe("Inbox"); + expect(templateNames(sqlite, "ws_member")).toEqual([]); + + expect(await ensureUserWorkspace(db, "usr_member", "member", "ja")).toEqual({ + workspaceId: "ws_member", + role: "member", + }); + + expect(notebookNames(sqlite, "ws_member")).toEqual({ + inbox: "受信トレイ", + "work-projects": "仕事のプロジェクト", + "learning-resources": "学習資料", + "creative-ideas": "アイデアと創作", + "personal-life": "暮らしと個人", + }); + expect(templateNames(sqlite, "ws_member")).toContain("ひらめきメモ"); + expect(pendingWorkspaces(sqlite)).toEqual([DEFAULT_WORKSPACE_ID]); + expect(notebookNames(sqlite)).toEqual(CHINESE_NOTEBOOKS); + }); + + test("creates a self-provisioned workspace directly in the user's language", async () => { + const { sqlite, db } = freshInstallation(); + addUser(sqlite, "usr_owner", "admin"); + addUser(sqlite, "usr_writer", "writer"); + await ensureUserWorkspace(db, "usr_owner", "admin", "zh-CN"); + + const workspace = await ensureUserWorkspace(db, "usr_writer", "writer", "pl-PL,pl;q=0.9"); + + expect(workspace.role).toBe("member"); + expect(notebookNames(sqlite, workspace.workspaceId).inbox).toBe("Skrzynka"); + expect(templateNames(sqlite, workspace.workspaceId)).toContain("Szybka notatka"); + }); +}); diff --git a/apps/mobile/src/lib/session.tsx b/apps/mobile/src/lib/session.tsx index fbc777c2..9b7bc5c9 100644 --- a/apps/mobile/src/lib/session.tsx +++ b/apps/mobile/src/lib/session.tsx @@ -29,7 +29,7 @@ type SessionContextValue = { isLoading: boolean; session: MobileSession | null; client: ReturnType | null; - signIn: (input: { baseUrl: string; username: string; password: string }) => Promise; + signIn: (input: { baseUrl: string; username: string; password: string; locale?: string }) => Promise; signOut: () => Promise; }; @@ -80,7 +80,7 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => { }); }, [queryClient, session]); - const signIn = useCallback(async (input: { baseUrl: string; username: string; password: string }) => { + const signIn = useCallback(async (input: { baseUrl: string; username: string; password: string; locale?: string }) => { const baseUrl = normalizeInstanceUrl(resolveInstanceUrlInput(input.baseUrl)); const deviceId = await getOrCreateDeviceId(); const loginClient = createEdgeEverClient({ baseUrl, fetch: expoFetch as typeof fetch }); @@ -88,6 +88,7 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => { username: input.username, password: input.password, deviceId, + locale: input.locale, }); if (!authSession.authenticated || !authSession.sessionToken) { diff --git a/apps/mobile/src/screens/LoginScreen.tsx b/apps/mobile/src/screens/LoginScreen.tsx index f050e86a..ae45832f 100644 --- a/apps/mobile/src/screens/LoginScreen.tsx +++ b/apps/mobile/src/screens/LoginScreen.tsx @@ -40,7 +40,7 @@ export const LoginScreen = () => { setError(null); try { - await signIn({ baseUrl, username, password }); + await signIn({ baseUrl, username, password, locale: resolvedLocale }); } catch (signInError) { setError(formatMobileLoginError(signInError, resolvedLocale)); } finally { diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index afaa1757..43a63380 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -60,7 +60,7 @@ const EvernoteMigrationRoute = () => { const AuthenticatedWorkspace = () => { const queryClient = useQueryClient(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const desktopBridge = window.edgeeverDesktop; const [desktopScopeReady, setDesktopScopeReady] = useState(() => !desktopBridge?.isAvailable); const [desktopScopeError, setDesktopScopeError] = useState(null); @@ -115,7 +115,11 @@ const AuthenticatedWorkspace = () => { if (desktopBridge?.isAvailable && payload.instanceUrl !== undefined) { await saveDesktopApiBaseUrl(payload.instanceUrl); } - const session = await api.login({ username: payload.username, password: payload.password }); + const session = await api.login({ + username: payload.username, + password: payload.password, + locale: i18n.resolvedLanguage, + }); await cacheDesktopSession(session); return session; }, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4dcb9571..4dddb28c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -350,7 +350,7 @@ export const api = { return session; }, - login: async (payload: { username: string; password: string }) => { + login: async (payload: { username: string; password: string; locale?: string }) => { const session = await client.login({ ...payload, deviceId: getOrCreateClientDeviceId() }); desktopSessionRejected = false; return session; diff --git a/migrations/0070_workspace_seed_localization.sql b/migrations/0070_workspace_seed_localization.sql new file mode 100644 index 00000000..7494c2f7 --- /dev/null +++ b/migrations/0070_workspace_seed_localization.sql @@ -0,0 +1,32 @@ +-- Default notebooks, the welcome note, and the default templates of a +-- workspace are localized once, at the first sign-in of the workspace's user, +-- using that user's resolved UI locale. A row here means the workspace still +-- waits for that step; the sign-in removes it in the same transaction that +-- localizes the seed data, so a failed attempt is retried on the next sign-in. +CREATE TABLE IF NOT EXISTS workspace_seed_localizations ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- Only an installation that nobody has used yet qualifies: no user has ever +-- signed in, and the default workspace holds nothing beyond what the +-- migrations seed. Existing installations, including unclaimed ones that +-- already hold data, keep their notebooks and welcome note unchanged. +INSERT OR IGNORE INTO workspace_seed_localizations (workspace_id) +SELECT 'ws_default' +WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = 'ws_default') + AND NOT EXISTS (SELECT 1 FROM users) + AND NOT EXISTS (SELECT 1 FROM workspace_members) + AND NOT EXISTS (SELECT 1 FROM memos WHERE id <> 'memo_welcome') + AND NOT EXISTS ( + SELECT 1 FROM memo_contents + WHERE memo_id = 'memo_welcome' + AND content_hash <> '4edcf7092f86c561210d4eb788e96f28ec5c045f93952c82f5f57e1902f548bd' + ) + AND NOT EXISTS (SELECT 1 FROM memo_revisions) + AND NOT EXISTS (SELECT 1 FROM resources) + AND NOT EXISTS ( + SELECT 1 FROM notebooks + WHERE slug IS NULL + OR slug NOT IN ('inbox', 'work-projects', 'learning-resources', 'creative-ideas', 'personal-life') + ); diff --git a/packages/shared/package.json b/packages/shared/package.json index a988ac04..a4c3cecb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,6 +15,7 @@ "./i18n": "./src/i18n/index.ts", "./i18n/locales": "./src/i18n/locales.ts", "./memo-template-seeds": "./src/memo-template-seeds.ts", + "./default-workspace-seeds": "./src/default-workspace-seeds.ts", "./mathematics": "./src/mathematics.ts", "./mobile-editor": "./src/mobile-editor.ts", "./mobile-ui": "./src/mobile-ui.ts", diff --git a/packages/shared/src/default-workspace-seeds.ts b/packages/shared/src/default-workspace-seeds.ts new file mode 100644 index 00000000..04e8a852 --- /dev/null +++ b/packages/shared/src/default-workspace-seeds.ts @@ -0,0 +1,130 @@ +import { resolveSupportedLocale, type SupportedLocale } from "./i18n/locales"; + +export type DefaultNotebookSeed = { + key: "inbox" | "projects" | "learning" | "creative" | "personal"; + slug: string; + color: string; + sortOrder: number; + names: Record; +}; + +// zh-CN is the language the installation migrations seed. Keep it first so +// the localization guard can recognise names that nobody has changed yet. +export const DEFAULT_NOTEBOOK_SEEDS: readonly DefaultNotebookSeed[] = [ + { + key: "inbox", + slug: "inbox", + color: "#0f766e", + sortOrder: 10, + names: { "zh-CN": "等待分类", "en-US": "Inbox", ja: "受信トレイ", pl: "Skrzynka" }, + }, + { + key: "projects", + slug: "work-projects", + color: "#2563eb", + sortOrder: 20, + names: { "zh-CN": "工作项目", "en-US": "Work Projects", ja: "仕事のプロジェクト", pl: "Projekty zawodowe" }, + }, + { + key: "learning", + slug: "learning-resources", + color: "#7c3aed", + sortOrder: 30, + names: { "zh-CN": "学习资料", "en-US": "Learning Resources", ja: "学習資料", pl: "Materiały do nauki" }, + }, + { + key: "creative", + slug: "creative-ideas", + color: "#db2777", + sortOrder: 40, + names: { "zh-CN": "灵感创作", "en-US": "Creative Ideas", ja: "アイデアと創作", pl: "Pomysły i twórczość" }, + }, + { + key: "personal", + slug: "personal-life", + color: "#ea580c", + sortOrder: 50, + names: { "zh-CN": "生活个人", "en-US": "Personal Life", ja: "暮らしと個人", pl: "Życie prywatne" }, + }, +]; + +export const localizeDefaultNotebookName = ( + seed: DefaultNotebookSeed, + locale: string | null | undefined, +) => seed.names[resolveSupportedLocale(locale)]; + +export const WELCOME_MEMO_ID = "memo_welcome"; + +/** + * The welcome note as the installation migrations leave it (0001_initial.sql, + * repaired by 0014_repair_seed_memo_content_hash.sql): its title, the content + * hash, and revision 0 written by the `system` actor. Any edit, including a + * title-only one, changes the title, the revision, or the actor, so together + * they identify a welcome note nobody has touched. + */ +export const WELCOME_MEMO_SEED_TITLE = "欢迎来到 EdgeEver"; +export const WELCOME_MEMO_SEED_CONTENT_HASH = "4edcf7092f86c561210d4eb788e96f28ec5c045f93952c82f5f57e1902f548bd"; + +export type WelcomeMemoTranslation = { + title: string; + paragraphs: [string, string]; +}; + +export const WELCOME_MEMO_TRANSLATIONS: Record = { + "zh-CN": { + title: "欢迎来到 EdgeEver", + paragraphs: [ + "这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。", + "接下来可以创建笔记本、写笔记、搜索内容,并把多条笔记合并成一条新的长期笔记。", + ], + }, + "en-US": { + title: "Welcome to EdgeEver", + paragraphs: [ + "This is your first EdgeEver note: three panes, running on the edge, ready for AI agents.", + "Next, create notebooks, write notes, search your content, and merge several notes into a new long-term note.", + ], + }, + ja: { + title: "EdgeEver へようこそ", + paragraphs: [ + "これは EdgeEver の最初のノートです。3 ペイン構成でエッジ上で動作し、AI エージェントとも連携できます。", + "ノートブックを作成してノートを書き、内容を検索し、複数のノートを 1 つの新しい長期ノートにまとめてみましょう。", + ], + }, + pl: { + title: "Witaj w EdgeEver", + paragraphs: [ + "To Twoja pierwsza notatka w EdgeEver: trzy panele, działanie na brzegu sieci i gotowość do pracy z agentami AI.", + "Teraz możesz tworzyć notatniki, pisać notatki, przeszukiwać treści i scalać kilka notatek w jedną nową, trwałą notatkę.", + ], + }, +}; + +export type WelcomeMemoContent = { + title: string; + excerpt: string; + contentJson: string; + contentMarkdown: string; + contentText: string; +}; + +// Mirrors the document shape of the welcome note in migrations/0001_initial.sql: +// a level-2 heading followed by two paragraphs. +export const buildWelcomeMemoContent = (locale: string | null | undefined): WelcomeMemoContent => { + const { title, paragraphs } = WELCOME_MEMO_TRANSLATIONS[resolveSupportedLocale(locale)]; + const text = (value: string) => [{ type: "text", text: value }]; + return { + title, + excerpt: paragraphs[0], + contentJson: JSON.stringify({ + type: "doc", + content: [ + { type: "heading", attrs: { level: 2 }, content: text(title) }, + ...paragraphs.map((paragraph) => ({ type: "paragraph", content: text(paragraph) })), + ], + }), + contentMarkdown: [`## ${title}`, ...paragraphs].join("\n\n"), + contentText: [title, ...paragraphs].join(" "), + }; +}; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 7b4216fb..bccfb563 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -173,6 +173,9 @@ export const LoginSchema = z.object({ username: z.string().trim().min(1).max(80), password: z.string().min(1).max(512), deviceId: z.string().trim().min(16).max(160).optional(), + // The client's resolved UI locale. Used once, to localize a new workspace's + // default notebooks and notes at its first sign-in. + locale: z.string().trim().min(1).max(35).optional(), }); export const LoginDeviceSessionUpdateSchema = z.object({