Skip to content
Closed
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/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);
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/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 ?? "");
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/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
22 changes: 13 additions & 9 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 @@ -75,12 +75,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;
}

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
21 changes: 14 additions & 7 deletions src/components/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ export interface SearchResultCardPageItem extends SearchResultCardBase {
kind: "page";
pageId: string;
/**
* 所属ノート ID。`null` は個人ページ(`Boolean(noteId)` で shared/personal を
* 判定)。note-native ページは `/notes/:noteId/:pageId` へ遷移する(Issue #889
* Phase 3)。`Page.noteId` の暫定 `string | null` を反映。
* Owning note id; `null` is a personal page (shared vs personal is decided by
* `Boolean(noteId)`). Mirrors the interim `string | null` on `Page.noteId`.
* 所属ノート ID。`/notes/:noteId/:pageId` 遷移用(Issue #889 Phase 3)。
* Issue #1020 以降は常に非 null。
* Owning note id used for `/notes/:noteId/:pageId` routing (Issue #889
* Phase 3). Always non-null since issue #1020.
*/
noteId: string | null;
noteId: string;
/**
* 共有検索(参加ノート横断の API 検索)由来の行か。「共有」バッジの表示に使う。
* `noteId` の有無では判定できない(Issue #1020 で全ページが noteId を持つため)。
* Whether the row came from the shared (cross-note API) search; drives the
* "共有" badge. Cannot be derived from `noteId` anymore — every page carries
* one since issue #1020.
*/
isShared: boolean;
sourceUrl?: string;
}

Expand Down Expand Up @@ -101,7 +108,7 @@ export function SearchResultCard({ item, onClick }: SearchResultCardProps) {
);

const isPdf = item.kind === "pdf_highlight";
const isShared = item.kind === "page" && Boolean(item.noteId);
const isShared = item.kind === "page" && item.isShared;
const hasSourceUrl = item.kind === "page" && Boolean(item.sourceUrl);

return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/wikiLink/WikiLinkPreviewContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function renderWithI18n(ui: React.ReactElement) {
const createMockPage = (overrides?: Partial<Page>): Page => ({
id: "page-1",
ownerUserId: "user-1",
noteId: null,
noteId: "note-default",
title: "テストページ",
content:
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"これはテストのプレビューです"}]}]}',
Expand Down
Loading