Skip to content
Open
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
18 changes: 17 additions & 1 deletion apps/app/app/(main)/browser/_hooks/useBrowserState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,23 @@ export function useBrowserState({
);
const data = await response.json();
if (!response.ok) {
setAiError(data.error ?? "요청에 실패했어요");
if (response.status === 401) {
setAiError(
"로그인 상태를 확인해 주세요. 작성 중인 메모는 그대로 유지돼요.",
);
} else if (response.status === 402 || response.status === 403) {
setAiError(
"현재 계정 또는 앱에서는 이 AI 기능을 사용할 수 없어요. 일반 메모는 계속 사용할 수 있어요.",
);
} else if (response.status === 429) {
setAiError(
"AI 이용 한도에 도달했어요. 일반 메모는 계속 사용할 수 있어요.",
);
} else {
setAiError(
"AI 요청을 완료하지 못했어요. 작성 중인 메모는 그대로 유지돼요.",
);
}
return;
}
if (question) {
Expand Down
75 changes: 75 additions & 0 deletions apps/app/lib/storage/syncService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
getMemoByUrl: vi.fn(),
insertMemo: vi.fn(),
updateMemo: vi.fn(),
getUnsyncedMemos: vi.fn(),
markAsSynced: vi.fn(),
clearSyncedMemos: vi.fn(),
}));
vi.mock("@/lib/supabase/client", () => ({
supabase: { auth: { getSession: mocks.getSession } },
}));
vi.mock("@web-memo/shared/utils/services", () => ({
MemoService: class {
getMemoByUrl = mocks.getMemoByUrl;
insertMemo = mocks.insertMemo;
updateMemo = mocks.updateMemo;
},
}));
vi.mock("./localMemo", () => ({
getUnsyncedMemos: mocks.getUnsyncedMemos,
markAsSynced: mocks.markAsSynced,
clearSyncedMemos: mocks.clearSyncedMemos,
}));

import { syncMemosToSupabase } from "./syncService";

beforeEach(() => {
vi.clearAllMocks();
mocks.getSession.mockResolvedValue({
data: { session: { user: { id: "user" } } },
});
mocks.getUnsyncedMemos.mockResolvedValue([
{
id: "local-1",
url: "https://example.com",
title: "Draft",
memo: "Keep this draft",
},
]);
mocks.getMemoByUrl.mockResolvedValue({ data: [], error: null });
mocks.insertMemo.mockResolvedValue({ data: [{ id: 1 }], error: null });
});

describe("local memo synchronization", () => {
it("keeps drafts unsynced when the free quota rejects an insert", async () => {
mocks.insertMemo.mockResolvedValue({
data: null,
error: { message: "FREE_MEMO_LIMIT_REACHED" },
});
expect(await syncMemosToSupabase()).toEqual({ synced: 0, failed: 1 });
expect(mocks.markAsSynced).not.toHaveBeenCalled();
expect(mocks.clearSyncedMemos).not.toHaveBeenCalled();
});
it("does not treat a failed lookup as permission to insert", async () => {
mocks.getMemoByUrl.mockResolvedValue({
data: null,
error: { message: "offline" },
});
expect(await syncMemosToSupabase()).toEqual({ synced: 0, failed: 1 });
expect(mocks.insertMemo).not.toHaveBeenCalled();
});
it("requires returned server rows before marking a draft synced", async () => {
mocks.insertMemo.mockResolvedValue({ data: [], error: null });
expect(await syncMemosToSupabase()).toEqual({ synced: 0, failed: 1 });
expect(mocks.markAsSynced).not.toHaveBeenCalled();
});
it("keeps the local copy even after a confirmed server save", async () => {
expect(await syncMemosToSupabase()).toEqual({ synced: 1, failed: 0 });
expect(mocks.markAsSynced).toHaveBeenCalledWith(["local-1"]);
expect(mocks.clearSyncedMemos).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions apps/app/lib/storage/syncService.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
import { MemoService } from "@web-memo/shared/utils/services";
import { supabase } from "@/lib/supabase/client";
import { clearSyncedMemos, getUnsyncedMemos, markAsSynced } from "./localMemo";
import { getUnsyncedMemos, markAsSynced } from "./localMemo";

const memoService = new MemoService(supabase);

export async function syncMemosToSupabase(): Promise<{
/** 서버 저장을 확인한 항목만 동기화로 표시하고 로컬 사본을 삭제하지 않습니다. */
export const syncMemosToSupabase = async (): Promise<{
synced: number;
failed: number;
}> {
}> => {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return { synced: 0, failed: 0 };
if (!session) {
return { synced: 0, failed: 0 };
}

const unsynced = await getUnsyncedMemos();
if (unsynced.length === 0) return { synced: 0, failed: 0 };
if (unsynced.length === 0) {
return { synced: 0, failed: 0 };
}

const syncedIds: string[] = [];
let failed = 0;

for (const memo of unsynced) {
try {
const existing = await memoService.getMemoByUrl(memo.url);
if (existing.error) {
throw existing.error;
}

if (existing.data && existing.data.length > 0) {
await memoService.updateMemo({
const result = await memoService.updateMemo({
id: existing.data[0].id,
request: {
url: memo.url,
Expand All @@ -36,8 +44,11 @@ export async function syncMemosToSupabase(): Promise<{
isWish: memo.isWish ?? existing.data[0].isWish,
},
});
if (result.error || !result.data?.length) {
throw result.error ?? new Error("MEMO_SYNC_NOT_CONFIRMED");
}
} else {
await memoService.insertMemo({
const result = await memoService.insertMemo({
url: memo.url,
title: memo.title,
memo: memo.memo,
Expand All @@ -46,6 +57,9 @@ export async function syncMemosToSupabase(): Promise<{
favIconUrl: memo.favIconUrl ?? null,
isWish: memo.isWish ?? false,
});
if (result.error || !result.data?.length) {
throw result.error ?? new Error("MEMO_SYNC_NOT_CONFIRMED");
}
}

syncedIds.push(memo.id);
Expand All @@ -56,8 +70,7 @@ export async function syncMemosToSupabase(): Promise<{

if (syncedIds.length > 0) {
await markAsSynced(syncedIds);
await clearSyncedMemos();
}

return { synced: syncedIds.length, failed };
}
};
33 changes: 33 additions & 0 deletions apps/chrome-extension/public/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,5 +214,38 @@
},
"actionItemPlaceholder": {
"message": "Write what to do after reading this page"
},
"billing_ai_signin": {
"message": "Please check your sign-in status. Ordinary memos are still available."
},
"billing_ai_paid": {
"message": "Summaries, automatic categories and page chat require a paid subscription."
},
"billing_ai_limit": {
"message": "Your AI usage is limited. You can still save ordinary memos."
},
"billing_ai_failed": {
"message": "The AI request could not complete. Your memos and existing responses are preserved."
},
"billing_loading": {
"message": "Checking your subscription."
},
"billing_load_failed": {
"message": "Subscription status is unavailable. This does not mean your plan changed to free."
},
"billing_ai_usage": {
"message": "AI uses this period:"
},
"billing_memo_limit": {
"message": "All 50 cloud memo slots are used, including trash. Existing memos remain editable and unsaved input is preserved."
},
"billing_ai_safety": {
"message": "Up to 30 AI uses per period; a cost safety limit may restrict use earlier."
},
"billing_view_pricing": {
"message": "View pricing on the web"
},
"billing_refresh": {
"message": "Refresh status"
}
}
33 changes: 33 additions & 0 deletions apps/chrome-extension/public/_locales/ko/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,5 +214,38 @@
},
"actionItemPlaceholder": {
"message": "이 페이지를 보고 할 일을 적어보세요"
},
"billing_ai_signin": {
"message": "로그인 상태를 확인해 주세요. 일반 메모는 계속 사용할 수 있어요."
},
"billing_ai_paid": {
"message": "요약·자동 분류·페이지 채팅은 유료 구독에서 사용할 수 있어요."
},
"billing_ai_limit": {
"message": "AI 이용 한도에 도달했어요. 일반 메모는 계속 저장할 수 있어요."
},
"billing_ai_failed": {
"message": "AI 요청을 완료하지 못했어요. 메모와 기존 답변은 그대로 유지돼요."
},
"billing_loading": {
"message": "구독 상태를 확인하고 있어요."
},
"billing_load_failed": {
"message": "구독 상태를 확인하지 못했어요. 무료로 변경된 것은 아니에요."
},
"billing_ai_usage": {
"message": "이번 주기 AI 사용량:"
},
"billing_memo_limit": {
"message": "클라우드 메모 50개를 모두 사용하고 있어요. 휴지통도 포함돼요. 기존 메모는 계속 편집할 수 있고, 저장되지 않은 입력은 그대로 유지돼요."
},
"billing_ai_safety": {
"message": "AI는 주기당 최대 30회이며 비용 안전 한도로 더 일찍 제한될 수 있어요."
},
"billing_view_pricing": {
"message": "웹에서 요금제 보기"
},
"billing_refresh": {
"message": "상태 새로고침"
}
}
Loading
Loading