Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@
"aws-jwt-verify",
"ioredis",
"@types/sql.js",
"@types/uuid",
"@vitejs/plugin-react",
"wrangler",
"@tauri-apps/plugin-shell",
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,6 @@
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"tesseract.js": "^7.0.0",
"uuid": "^14.0.0",
"vaul": "^1.1.2",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
Expand Down Expand Up @@ -233,7 +232,6 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/sql.js": "^1.4.9",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react-swc": "^4.2.3",
"@vitest/coverage-v8": "^4.0.18",
Expand Down
42 changes: 42 additions & 0 deletions server/api/src/__tests__/routes/syncPages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,47 @@ function createSyncApp(dbResults: unknown[]) {
return { app, chains: mock.chains };
}

describe("GET /api/sync/pages — note_id / default_note_id in response (issue #1020)", () => {
it("returns note_id on each page row and default_note_id at the top level", async () => {
const now = new Date("2025-06-01T00:00:00Z");
const { app } = createSyncApp([
// 1: pages query
[
{
id: OWNED_PAGE,
owner_id: TEST_USER_ID,
note_id: "sync-default-note-id",
title: "P",
content_preview: null,
thumbnail_url: null,
source_url: null,
source_page_id: null,
is_deleted: false,
created_at: now,
updated_at: now,
},
],
// 2: links query
[],
// 3: ghost_links query
[],
]);

const res = await app.request("/api/sync/pages", {
method: "GET",
headers: authHeaders(),
});

expect(res.status).toBe(200);
const body = (await res.json()) as {
pages: Array<{ id: string; note_id: string }>;
default_note_id: string;
};
expect(body.default_note_id).toBe("sync-default-note-id");
expect(body.pages[0]?.note_id).toBe("sync-default-note-id");
});
});

describe("GET /api/sync/pages — link_type in response (issue #725 Phase 1)", () => {
it("returns link_type on each links row and ghost_links row", async () => {
const now = new Date("2025-06-01T00:00:00Z");
Expand All @@ -72,6 +113,7 @@ describe("GET /api/sync/pages — link_type in response (issue #725 Phase 1)", (
{
id: OWNED_PAGE,
owner_id: TEST_USER_ID,
note_id: "sync-default-note-id",
title: "P",
content_preview: null,
thumbnail_url: null,
Expand Down
10 changes: 10 additions & 0 deletions server/api/src/routes/syncPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ app.get("/", authRequired, async (c) => {
.select({
id: pages.id,
owner_id: pages.ownerId,
// Issue #1020: クライアントが `noteId: null`(旧個人ページ)を生成し続け
// ないよう、各行の所属ノート ID をワイヤに載せる。
// Issue #1020: carry the owning note id so clients stop materializing
// legacy `noteId: null` personal pages.
note_id: pages.noteId,
title: pages.title,
content_preview: pages.contentPreview,
thumbnail_url: pages.thumbnailUrl,
Expand Down Expand Up @@ -110,6 +115,11 @@ app.get("/", authRequired, async (c) => {
original_target_page_id: g.originalTargetPageId,
original_note_id: g.originalNoteId,
})),
// Issue #1020: 既存クライアントの `noteId: null` 行をデフォルトノートへ
// 付け替えるための移行情報。pages が 0 件でも参照できるようトップレベルに置く。
// Issue #1020: lets clients reassign legacy `noteId: null` rows to the
// default note even when the pull returns zero pages.
default_note_id: defaultNote.id,
server_time: new Date().toISOString(),
});
});
Expand Down
14 changes: 9 additions & 5 deletions src/components/aiChat/AIChatWikiLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@zedi/ui";
import { useWikiLinkNavigation } from "@/components/editor/TiptapEditor/useWikiLinkNavigation";
import { CreatePageDialog } from "@/components/editor/TiptapEditor/CreatePageDialog";
import { usePageStore } from "../../stores/pageStore";
import { usePageByTitle, useGhostLinkReferenced } from "@/hooks/pages/usePageQueries";
import { WikiLinkPreviewContent } from "../wikiLink/WikiLinkPreviewContent";

interface AIChatWikiLinkProps {
Expand All @@ -27,10 +27,14 @@ const LONG_PRESS_MS = 500;
*/
export function AIChatWikiLink({ title }: AIChatWikiLinkProps) {
const normalizedTitle = title.trim();
const page = usePageStore((state) => state.getPageByTitle(normalizedTitle));
const referenced = usePageStore(
(state) => !page && state.ghostLinks.some((gl) => gl.linkText === normalizedTitle),
);
// 旧ゲストストア (`pageStore`) は Issue #1020 で廃止したため、リポジトリ
// (IndexedDB)ベースのクエリでページ解決とゴースト参照判定を行う。
// The legacy guest store (`pageStore`) was retired by issue #1020; resolve
// the page and the ghost-reference state via the repository (IndexedDB).
const { data: resolvedPage } = usePageByTitle(normalizedTitle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If normalizedTitle is empty, calling usePageByTitle will trigger an unnecessary IndexedDB query. To prevent redundant database access, consider passing an enabled option to skip the query when the title is empty.

Suggested change
const { data: resolvedPage } = usePageByTitle(normalizedTitle);
const { data: resolvedPage } = usePageByTitle(normalizedTitle, { enabled: !!normalizedTitle });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応不要と判断しました。usePageByTitle は内部で enabled: isLoaded && title.trim().length > 0 をガードしており、空タイトルでクエリは発火しません(options 引数も受け取らないため提案のシグネチャはコンパイルできません)。

No change needed: usePageByTitle already has the internal guard enabled: isLoaded && title.trim().length > 0, so an empty title never hits IndexedDB (and the hook takes no options parameter).


Generated by Claude Code

const page = resolvedPage ?? undefined;
const { data: ghostReferenced = false } = useGhostLinkReferenced(normalizedTitle);
const referenced = !page && ghostReferenced;

const {
handleLinkClick: navigateWikiLinkByTitle,
Expand Down
15 changes: 5 additions & 10 deletions src/components/aiChat/PromoteToWikiDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,17 +245,12 @@ function PromoteToWikiDialogBody({
),
);

// Issue #889 Phase 3: 遷移先は `/notes/:noteId/:pageId` のため、`id` と
// `noteId` の両方が揃ったページのみ "successful" として扱う。`noteId`
// 欠落時は不正な URL を組み立ててしまうので作成失敗扱いにする。
// Issue #889 Phase 3: 遷移先は `/notes/:noteId/:pageId`。`Page.noteId` は
// Issue #1020 以降 non-null なので、作成成功ページがあればそのまま遷移できる。
// Issue #889 Phase 3: the canonical landing route is
// `/notes/:noteId/:pageId`, so both `id` and `noteId` must be present.
// Treat a missing `noteId` as a failed creation rather than navigating
// to `/notes/undefined/...`.
const firstCreated = created.find(
(p): p is NonNullable<typeof p> & { noteId: string } =>
p != null && Boolean(p.id) && Boolean(p.noteId),
);
// `/notes/:noteId/:pageId`; `Page.noteId` is non-null since issue #1020,
// so any successfully created page can be navigated to directly.
const firstCreated = created.find((p): p is NonNullable<typeof p> => p != null);
if (!firstCreated) throw new Error("no pages created");

const firstEntity = selectedEntities[created.indexOf(firstCreated)];
Expand Down
19 changes: 9 additions & 10 deletions src/components/editor/TiptapEditor/WikiLinkHoverCardLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useCallback } from "react";
import { createPortal } from "react-dom";
import type { Editor } from "@tiptap/core";
import { usePageStore } from "@/stores/pageStore";
import { usePageByTitle, useGhostLinkReferenced } from "@/hooks/pages/usePageQueries";
import { WikiLinkPreviewContent } from "@/components/wikiLink/WikiLinkPreviewContent";
import { useWikiLinkHover } from "./useWikiLinkHover";

Expand Down Expand Up @@ -39,16 +39,15 @@ export const WikiLinkHoverCardLayer: React.FC<WikiLinkHoverCardLayerProps> = ({
const { target, isVisible, cardRef, closeCard, handleCardMouseEnter, handleCardMouseLeave } =
useWikiLinkHover(editor, editorContainerRef);

const page = usePageStore((state) => {
if (!target) return undefined;
return state.getPageByTitle(target.title);
});
// 旧ゲストストア (`pageStore`) は Issue #1020 で廃止したため、リポジトリ
// (IndexedDB)ベースのクエリでページ解決とゴースト参照判定を行う。
// The legacy guest store (`pageStore`) was retired by issue #1020; resolve
// the page and the ghost-reference state via the repository (IndexedDB).
const { data: resolvedPage } = usePageByTitle(target?.title ?? "");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When target is undefined (e.g., when the hover card is not active), target?.title evaluates to undefined, defaulting to "". This will trigger an unnecessary IndexedDB query for an empty title on every render. To optimize performance, consider passing an enabled option to usePageByTitle so the query is skipped when the title is empty.

Suggested change
const { data: resolvedPage } = usePageByTitle(target?.title ?? "");
const { data: resolvedPage } = usePageByTitle(target?.title ?? "", { enabled: !!target?.title });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応不要と判断しました。usePageByTitle は内部で enabled: isLoaded && title.trim().length > 0 をガードしており、空タイトルではクエリは発火しません(また、このフックは options 引数を受け取らないため提案のシグネチャはコンパイルできません)。useGhostLinkReferenced 側も同様に normalized.length > 0 でガード済みです。

No change needed: usePageByTitle already guards internally with enabled: isLoaded && title.trim().length > 0, so an empty title never triggers an IndexedDB query (and the hook takes no options parameter, so the suggested signature would not compile). useGhostLinkReferenced has the same internal guard.


Generated by Claude Code

const page = resolvedPage ?? undefined;

const referenced = usePageStore((state) => {
if (!target) return false;
const resolved = state.getPageByTitle(target.title);
return !resolved && state.ghostLinks.some((gl) => gl.linkText === target.title);
});
const { data: ghostReferenced = false } = useGhostLinkReferenced(target?.title ?? "");
const referenced = !page && ghostReferenced;

const handleCardClick = useCallback(() => {
if (!target) return;
Expand Down
22 changes: 12 additions & 10 deletions src/components/editor/TiptapEditor/useWikiLinkNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { useNoteTitleIndex } from "@/hooks/notes/useNoteQueries";

interface UseWikiLinkNavigationOptions {
/**
* 編集中ページの noteId。`null` はレガシー個人ページ呼び出しの fallback で、
* Issue #889 Phase 3 で `/pages/:id` ルートが廃止された後は、解決された
* `foundPage.noteId` を使って `/notes/:noteId/:pageId` に統合的に遷移する。
* 通常はノート ID を渡す(Issue #713 Phase 4 / #889 Phase 3)。
* 編集中ページの noteId。`null` はノートコンテキスト外(AI チャット等)からの
* 呼び出しを表し、ローカル(IndexedDB=デフォルトノート)のページ集合で解決
* する。遷移は常に解決された `foundPage.noteId` を使って
* `/notes/:noteId/:pageId` に着地する(Issue #889 Phase 3 / #1020)。
*
* Owning note ID of the page being edited. `null` is kept for legacy
* personal-page callers, but Issue #889 Phase 3 retired `/pages/:id` so
* navigation always lands on `/notes/:noteId/:pageId` using the resolved
* `foundPage.noteId`. Callers normally pass the owning note id.
* Owning note ID of the page being edited. `null` means the caller has no
* note context (e.g. AI chat) and resolution runs against the local
* IndexedDB set (the default note). Navigation always lands on
* `/notes/:noteId/:pageId` via the resolved `foundPage.noteId`
* (issues #889 Phase 3 / #1020).
*/
pageNoteId: string | null;
}
Expand Down Expand Up @@ -44,11 +45,12 @@ interface UseWikiLinkNavigationReturn {
* or shows a dialog to create a new page.
*
* WikiLink クリック時、`pageNoteId` に応じて候補スコープを切り替える。
* - `pageNoteId === null` → 個人ページのみを検索し、`/pages/:id` に遷移。
* - `pageNoteId === null` → ローカル(IndexedDB=デフォルトノート)の
* ページ集合を検索し、解決ページの noteId で `/notes/:noteId/:id` に遷移。
* - `pageNoteId !== null` → そのノート内のページのみを検索し、
* canonical ルート `/notes/:pageNoteId/:id` に遷移。
*
* Issue #713 Phase 4。
* Issue #713 Phase 4 / #1020
*/
export function useWikiLinkNavigation(
options: UseWikiLinkNavigationOptions = { pageNoteId: null },
Expand Down
40 changes: 30 additions & 10 deletions src/components/layout/FloatingActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import { useFloatingActionButtonHandlers } from "./useFloatingActionButtonHandle
type FloatingActionButtonProps = {
noteId?: string;
/**
* 追加で非表示にするメニュー項目。未ログイン時の `url` 非表示ロジックは
* 内部で自動適用されるため、重ねて渡す必要はない
* 追加で非表示にするメニュー項目。未サインイン時は FAB 自体が描画されない
* (ページ作成はサインイン必須、Issue #1020)
*
* Additional menu options to hide. The built-in `url` hide rule for guests is
* applied automatically, so callers don't need to re-specify it.
* Additional menu options to hide. For guests the FAB renders nothing at
* all — page creation requires sign-in (issue #1020).
*/
hiddenOptions?: FABMenuOption[];
} & (
Expand Down Expand Up @@ -62,7 +62,23 @@ const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({

useEffect(() => {
if (!isSignedIn) {
queueMicrotask(() => setIsWebClipperOpen(false));
// サインアウト遷移ではコンポーネントは unmount されず `null` を返すだけ
// なので、開いたままのメニュー / ダイアログ状態が残ると再サインイン時に
// 突然再表示される。ローカル UI 状態をここで畳む(PR #1023 CodeRabbit)。
// `initialClipUrl` の破棄はしない — サインイン往復(#826 の clipUrl
// handoff)ではセッション読込中に一時的に未サインイン判定になるため、
// ここで親に閉じ通知すると正規の handoff を壊してしまう。
// On sign-out the component stays mounted and merely renders null, so
// stale open-menu/dialog state would resurface on the next sign-in.
// Collapse the local UI state here (PR #1023 CodeRabbit review). Do NOT
// clear `initialClipUrl`: the sign-in round trip (#826 clipUrl handoff)
// passes through a transient unauthenticated state, and notifying the
// parent here would destroy the legitimate handoff.
queueMicrotask(() => {
setIsMenuOpen(false);
setIsWebClipperOpen(false);
setIsImageDialogOpen(false);
});
}
}, [isSignedIn]);

Expand All @@ -75,12 +91,16 @@ const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
noteId,
});

const mergedHidden: FABMenuOption[] = [
...(isSignedIn ? [] : (["url"] as FABMenuOption[])),
...(extraHiddenOptions ?? []),
];
const hiddenOptions: FABMenuOption[] | undefined =
mergedHidden.length > 0 ? mergedHidden : undefined;
extraHiddenOptions && extraHiddenOptions.length > 0 ? extraHiddenOptions : undefined;

// ページ作成はサインイン必須(Issue #1020 でゲストのローカル作成を廃止)。
// FAB のメニューは全てページ作成系のため、未サインイン時は FAB 自体を出さない。
// Page creation requires sign-in (guest-local creation was retired by issue
// #1020). Every FAB menu option creates a page, so hide the FAB for guests.
if (!isSignedIn) {
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const fabButton = (
<TooltipProvider delayDuration={300}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/note/NotePagePublicView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const NotePagePublicView: React.FC<NotePagePublicViewProps> = ({ pageId,
showToolbar={false}
onContentChange={NOOP}
onContentError={NOOP}
pageNoteId={page.noteId ?? null}
pageNoteId={page.noteId}
/>
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/components/page/LinkGroupRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface LinkGroupRowProps {
* `/notes/:noteId/:pageId` 遷移用に noteId も渡す(Issue #889 Phase 3)。
* Passes `noteId` so the parent can build the `/notes/:noteId/:pageId` URL.
*/
onPageClick: (pageId: string, noteId: string | null) => void;
onPageClick: (pageId: string, noteId: string) => void;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/page/LinkSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface LinkSectionProps {
* `/notes/:noteId/:pageId` requires both ids — pass the page's `noteId` to
* the parent (Issue #889 Phase 3).
*/
onPageClick: (pageId: string, noteId: string | null) => void;
onPageClick: (pageId: string, noteId: string) => void;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/page/LinkedPagesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function LinkedPagesSection({
* Navigate to a linked page. `PageCard` carries `noteId`, so we can build
* `/notes/:noteId/:pageId` directly (Issue #889 Phase 3).
*/
const handlePageClick = (id: string, noteId: string | null) => {
const handlePageClick = (id: string, noteId: string) => {
navigate(`/notes/${noteId}/${id}`);
};

Expand Down
Loading
Loading