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({