From f95f67364a77ef3bc783d7b5b06951fc08ce97a1 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 25 Aug 2025 10:25:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=86=8C=EC=84=A4=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=20=EB=A9=94=EB=AA=A8=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐ŸŒŸ ์ฃผ์š” ๊ธฐ๋Šฅ: - ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ์‹œ์Šคํ…œ - ์†Œ์„ค ๋ถ„๊ธฐ ๊ด€๋ฆฌ (์ •์‚ฌ ๊ฒฝ๋กœ ์„ค์ •) - ์ค‘์•™ ๋ฐฐ์น˜ ํŠธ๋ฆฌ ๋‹ค์ด์–ด๊ทธ๋žจ - ์ •์‚ฌ ๊ฒฝ๋กœ ๋ชฉ์ฐจ ๋ทฐ - ์ธ๋ผ์ธ ํŽธ์ง‘ ๊ธฐ๋Šฅ ๐Ÿ“š ๋ฐฑ์—”๋“œ: - MemoTree, MemoNode ๋ชจ๋ธ ์ถ”๊ฐ€ - ์ •์‚ฌ ๊ฒฝ๋กœ ์ž๋™ ์ˆœ์„œ ๊ด€๋ฆฌ - ๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ ๊ฐ€๋Šฅํ•œ ๋กœ์ง - RESTful API ์—”๋“œํฌ์ธํŠธ ๐ŸŽจ ํ”„๋ก ํŠธ์—”๋“œ: - memo-tree.html: ํŠธ๋ฆฌ ๋‹ค์ด์–ด๊ทธ๋žจ ์—๋””ํ„ฐ - story-view.html: ์ •์‚ฌ ๊ฒฝ๋กœ ๋ชฉ์ฐจ ๋ทฐ - SVG ์—ฐ๊ฒฐ์„ ์œผ๋กœ ์‹œ๊ฐ์  ํŠธ๋ฆฌ ํ‘œํ˜„ - Alpine.js ๊ธฐ๋ฐ˜ ๋ฐ˜์‘ํ˜• UI - Monaco Editor ํ†ตํ•ฉ โœจ ํŠน๋ณ„ ๊ธฐ๋Šฅ: - ์ •์‚ฌ ๊ฒฝ๋กœ ํ™ฉ๊ธˆ์ƒ‰ ๋ฐฐ์ง€ ํ‘œ์‹œ - ํ™•๋Œ€/์ถ•์†Œ ๋ฐ ํŒจ๋‹ ์ง€์› - ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์ค€๋น„ - ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฐ ์ธ์‡„ ๊ธฐ๋Šฅ - ์ธ๋ผ์ธ ํŽธ์ง‘ ๋ชจ๋‹ฌ --- .../005_create_memo_tree_tables.sql | 153 +++ .../migrations/006_add_canonical_path.sql | 98 ++ .../migrations/007_fix_canonical_order.sql | 97 ++ backend/src/api/routes/memo_trees.py | 700 +++++++++++++ backend/src/main.py | 3 +- backend/src/models/memo_tree.py | 111 ++ backend/src/models/user.py | 5 + backend/src/schemas/memo_tree.py | 205 ++++ database/init/005_create_memo_tree_tables.sql | 153 +++ frontend/hierarchy.html | 5 +- frontend/index.html | 5 +- frontend/memo-tree.html | 651 ++++++++++++ frontend/static/js/api.js | 94 +- frontend/static/js/memo-tree.js | 984 ++++++++++++++++++ frontend/static/js/story-view.js | 345 ++++++ frontend/story-view.html | 389 +++++++ 16 files changed, 3992 insertions(+), 6 deletions(-) create mode 100644 backend/database/migrations/005_create_memo_tree_tables.sql create mode 100644 backend/database/migrations/006_add_canonical_path.sql create mode 100644 backend/database/migrations/007_fix_canonical_order.sql create mode 100644 backend/src/api/routes/memo_trees.py create mode 100644 backend/src/models/memo_tree.py create mode 100644 backend/src/schemas/memo_tree.py create mode 100644 database/init/005_create_memo_tree_tables.sql create mode 100644 frontend/memo-tree.html create mode 100644 frontend/static/js/memo-tree.js create mode 100644 frontend/static/js/story-view.js create mode 100644 frontend/story-view.html diff --git a/backend/database/migrations/005_create_memo_tree_tables.sql b/backend/database/migrations/005_create_memo_tree_tables.sql new file mode 100644 index 0000000..d7a011f --- /dev/null +++ b/backend/database/migrations/005_create_memo_tree_tables.sql @@ -0,0 +1,153 @@ +-- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- 005_create_memo_tree_tables.sql + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ (ํ”„๋กœ์ ํŠธ/์›Œํฌ์ŠคํŽ˜์ด์Šค) +CREATE TABLE memo_trees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general' + template_data JSONB, -- ํ…œํ”Œ๋ฆฟ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + settings JSONB DEFAULT '{}', -- ํŠธ๋ฆฌ๋ณ„ ์„ค์ • + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_public BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ (ํŠธ๋ฆฌ์˜ ๊ฐ ๋…ธ๋“œ) +CREATE TABLE memo_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- ๊ธฐ๋ณธ ์ •๋ณด + title VARCHAR(500) NOT NULL, + content TEXT, -- ์‹ค์ œ ๋ฉ”๋ชจ ๋‚ด์šฉ (Markdown) + node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot' + + -- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๊ด€๋ฆฌ + sort_order INTEGER DEFAULT 0, + depth_level INTEGER DEFAULT 0, + path TEXT, -- ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + + -- ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + tags TEXT[], -- ํƒœ๊ทธ ๋ฐฐ์—ด + node_metadata JSONB DEFAULT '{}', -- ๋…ธ๋“œ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์บ๋ฆญํ„ฐ ์ •๋ณด, ํ”Œ๋กฏ ์ •๋ณด ๋“ฑ) + + -- ์ƒํƒœ ๊ด€๋ฆฌ + status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete' + word_count INTEGER DEFAULT 0, + + -- ์‹œ๊ฐ„ ์ •๋ณด + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- ์ œ์•ฝ ์กฐ๊ฑด + CONSTRAINT no_self_reference CHECK (id != parent_id) +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ๊ด€๋ฆฌ (์„ ํƒ์ ) +CREATE TABLE memo_node_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + node_metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(node_id, version_number) +); + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  (ํ˜‘์—… ๊ธฐ๋Šฅ) +CREATE TABLE memo_tree_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin' + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(tree_id, shared_with_user_id) +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id); +CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type); +CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id); +CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id); +CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id); +CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/')); +CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags); +CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type); +CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id); +CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +CREATE TRIGGER memo_trees_updated_at + BEFORE UPDATE ON memo_trees + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +CREATE TRIGGER memo_nodes_updated_at + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: ๊ฒฝ๋กœ ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_node_path() +RETURNS TRIGGER AS $$ +BEGIN + -- ๋ฃจํŠธ ๋…ธ๋“œ์ธ ๊ฒฝ์šฐ + IF NEW.parent_id IS NULL THEN + NEW.path = '/' || NEW.id::text; + NEW.depth_level = 0; + ELSE + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ๊ฒฝ๋กœ๋ฅผ ๊ฐ€์ ธ์™€์„œ ํ™•์žฅ + SELECT path || '/' || NEW.id::text, depth_level + 1 + INTO NEW.path, NEW.depth_level + FROM memo_nodes + WHERE id = NEW.parent_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ +CREATE TRIGGER memo_nodes_path_update + BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_node_path(); + +-- ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ (๊ฐœ๋ฐœ์šฉ) +-- ์†Œ์„ค ํ…œํ”Œ๋ฆฟ ์˜ˆ์‹œ +INSERT INTO memo_trees (user_id, title, description, tree_type, template_data) +SELECT + u.id, + '๋‚ด ์ฒซ ๋ฒˆ์งธ ์†Œ์„ค', + 'ํŒํƒ€์ง€ ์†Œ์„ค ํ”„๋กœ์ ํŠธ', + 'novel', + '{ + "genre": "fantasy", + "target_length": 100000, + "chapters_planned": 20, + "main_characters": [], + "world_building": {} + }'::jsonb +FROM users u +WHERE u.email = 'admin@test.com' +LIMIT 1; diff --git a/backend/database/migrations/006_add_canonical_path.sql b/backend/database/migrations/006_add_canonical_path.sql new file mode 100644 index 0000000..1474cda --- /dev/null +++ b/backend/database/migrations/006_add_canonical_path.sql @@ -0,0 +1,98 @@ +-- 006_add_canonical_path.sql +-- ์ •์‚ฌ ๊ฒฝ๋กœ ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ํ•„๋“œ ์ถ”๊ฐ€ + +-- memo_nodes ํ…Œ์ด๋ธ”์— ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ ์ถ”๊ฐ€ +ALTER TABLE memo_nodes +ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE, +ADD COLUMN canonical_order INTEGER DEFAULT NULL, +ADD COLUMN story_path TEXT DEFAULT NULL; -- ์ •์‚ฌ ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ๋ฅผ ์œ„ํ•œ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ +CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE; + +-- ํŠธ๋ฆฌ๋ณ„ ์ •์‚ฌ ๊ฒฝ๋กœ ํ†ต๊ณ„๋ฅผ ์œ„ํ•œ ๋ทฐ ์ƒ์„ฑ +CREATE OR REPLACE VIEW memo_tree_canonical_stats AS +SELECT + t.id as tree_id, + t.title as tree_title, + COUNT(n.id) as total_nodes, + COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes, + MAX(n.canonical_order) as max_canonical_order, + STRING_AGG( + CASE WHEN n.is_canonical = TRUE THEN n.title END, + ' โ†’ ' + ORDER BY n.canonical_order + ) as canonical_story_path +FROM memo_trees t +LEFT JOIN memo_nodes n ON t.id = n.tree_id +GROUP BY t.id, t.title; + +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ ์ž๋™ ์—…๋ฐ์ดํŠธ ํ•จ์ˆ˜ (๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ ๊ฐ€๋Šฅ) +CREATE OR REPLACE FUNCTION update_canonical_order() +RETURNS TRIGGER AS $$ +BEGIN + -- ์ •์‚ฌ๋กœ ์„ค์ •๋  ๋•Œ + IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN + -- ๊ฐ™์€ ๋ถ€๋ชจ๋ฅผ ๊ฐ€์ง„ ๋‹ค๋ฅธ ํ˜•์ œ ๋…ธ๋“œ๋“ค์˜ ์ •์‚ฌ ์ƒํƒœ ํ•ด์ œ (๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ) + IF NEW.parent_id IS NOT NULL THEN + UPDATE memo_nodes + SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL + WHERE tree_id = NEW.tree_id + AND parent_id = NEW.parent_id + AND id != NEW.id + AND is_canonical = TRUE; + END IF; + + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ์ˆœ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ˆœ์„œ ๊ณ„์‚ฐ + IF NEW.parent_id IS NULL THEN + -- ๋ฃจํŠธ ๋…ธ๋“œ๋Š” ํ•ญ์ƒ 1 + NEW.canonical_order = 1; + ELSE + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ์ˆœ์„œ + 1 + SELECT COALESCE(parent.canonical_order, 0) + 1 + INTO NEW.canonical_order + FROM memo_nodes parent + WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE; + + -- ๋ถ€๋ชจ๊ฐ€ ์ •์‚ฌ๊ฐ€ ์•„๋‹ˆ๋ฉด ์ˆœ์„œ ํ• ๋‹น ์•ˆํ•จ + IF NEW.canonical_order IS NULL THEN + NEW.canonical_order = NULL; + END IF; + END IF; + + -- ์ •์‚ฌ ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ + NEW.story_path = COALESCE(NEW.path, ''); + END IF; + + -- ์ •์‚ฌ์—์„œ ์ œ์™ธ๋  ๋•Œ ์ˆœ์„œ ์ œ๊ฑฐ + IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN + NEW.canonical_order = NULL; + NEW.story_path = NULL; + + -- ๋’ค์˜ ์ˆœ์„œ๋“ค์„ ์•ž์œผ๋กœ ๋‹น๊ธฐ๊ธฐ + UPDATE memo_nodes + SET canonical_order = canonical_order - 1 + WHERE tree_id = NEW.tree_id + AND is_canonical = TRUE + AND canonical_order > OLD.canonical_order; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes; +CREATE TRIGGER trigger_update_canonical_order + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_canonical_order(); + +-- ๊ธฐ์กด ๋ฃจํŠธ ๋…ธ๋“œ๋“ค์„ ์ •์‚ฌ๋กœ ์„ค์ • (๊ธฐ๋ณธ๊ฐ’) +UPDATE memo_nodes +SET is_canonical = TRUE, canonical_order = 1 +WHERE parent_id IS NULL AND is_canonical = FALSE; + +COMMENT ON COLUMN memo_nodes.is_canonical IS '์ •์‚ฌ ๊ฒฝ๋กœ ์—ฌ๋ถ€ (์†Œ์„ค์˜ ๋ฉ”์ธ ์Šคํ† ๋ฆฌ๋ผ์ธ)'; +COMMENT ON COLUMN memo_nodes.canonical_order IS '์ •์‚ฌ ๊ฒฝ๋กœ์—์„œ์˜ ์ˆœ์„œ (1๋ถ€ํ„ฐ ์‹œ์ž‘)'; +COMMENT ON COLUMN memo_nodes.story_path IS '์ •์‚ฌ ๊ฒฝ๋กœ ๋ฌธ์ž์—ด ํ‘œํ˜„'; diff --git a/backend/database/migrations/007_fix_canonical_order.sql b/backend/database/migrations/007_fix_canonical_order.sql new file mode 100644 index 0000000..5be147d --- /dev/null +++ b/backend/database/migrations/007_fix_canonical_order.sql @@ -0,0 +1,97 @@ +-- 007_fix_canonical_order.sql +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ ๊ณ„์‚ฐ ๋กœ์ง ์ˆ˜์ • + +-- ๊ธฐ์กด ํŠธ๋ฆฌ๊ฑฐ ์‚ญ์ œ +DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes; +DROP FUNCTION IF EXISTS update_canonical_order(); + +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ณ„์‚ฐํ•˜๋Š” ํ•จ์ˆ˜ +CREATE OR REPLACE FUNCTION update_canonical_order() +RETURNS TRIGGER AS $$ +BEGIN + -- ์ •์‚ฌ๋กœ ์„ค์ •๋  ๋•Œ + IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN + -- ๊ฐ™์€ ๋ถ€๋ชจ๋ฅผ ๊ฐ€์ง„ ๋‹ค๋ฅธ ํ˜•์ œ ๋…ธ๋“œ๋“ค์˜ ์ •์‚ฌ ์ƒํƒœ ํ•ด์ œ (๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ) + IF NEW.parent_id IS NOT NULL THEN + UPDATE memo_nodes + SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL + WHERE tree_id = NEW.tree_id + AND parent_id = NEW.parent_id + AND id != NEW.id + AND is_canonical = TRUE; + END IF; + + -- ์ •์‚ฌ ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ + NEW.story_path = COALESCE(NEW.path, ''); + + -- ์ˆœ์„œ๋Š” ๋ณ„๋„ ํ•จ์ˆ˜์—์„œ ์ผ๊ด„ ๊ณ„์‚ฐ + PERFORM recalculate_canonical_orders(NEW.tree_id); + END IF; + + -- ์ •์‚ฌ์—์„œ ์ œ์™ธ๋  ๋•Œ + IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN + NEW.canonical_order = NULL; + NEW.story_path = NULL; + + -- ์ˆœ์„œ ์žฌ๊ณ„์‚ฐ + PERFORM recalculate_canonical_orders(NEW.tree_id); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๋ณ„ ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ๋ฅผ DFS๋กœ ์žฌ๊ณ„์‚ฐํ•˜๋Š” ํ•จ์ˆ˜ +CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID) +RETURNS VOID AS $$ +DECLARE + current_order INTEGER := 1; +BEGIN + -- ๋ชจ๋“  ์ •์‚ฌ ๋…ธ๋“œ์˜ ์ˆœ์„œ๋ฅผ NULL๋กœ ์ดˆ๊ธฐํ™” + UPDATE memo_nodes + SET canonical_order = NULL + WHERE tree_id = tree_uuid AND is_canonical = TRUE; + + -- DFS๋กœ ์ˆœ์„œ ํ• ๋‹น (์žฌ๊ท€ CTE ์‚ฌ์šฉ) + WITH RECURSIVE canonical_path AS ( + -- ๋ฃจํŠธ ๋…ธ๋“œ๋“ค (์ •์‚ฌ์ธ ๊ฒƒ๋งŒ) + SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path + FROM memo_nodes + WHERE tree_id = tree_uuid + AND parent_id IS NULL + AND is_canonical = TRUE + + UNION ALL + + -- ์ž์‹ ๋…ธ๋“œ๋“ค (์ •์‚ฌ์ธ ๊ฒƒ๋งŒ) + SELECT n.id, n.parent_id, n.title, + cp.order_num + 1 as order_num, + cp.path || n.id + FROM memo_nodes n + INNER JOIN canonical_path cp ON n.parent_id = cp.id + WHERE n.tree_id = tree_uuid + AND n.is_canonical = TRUE + ) + UPDATE memo_nodes + SET canonical_order = cp.order_num + FROM canonical_path cp + WHERE memo_nodes.id = cp.id; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ๋‹ค์‹œ ์ƒ์„ฑ +CREATE TRIGGER trigger_update_canonical_order + AFTER UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_canonical_order(); + +-- ๊ธฐ์กด ๋ฐ์ดํ„ฐ์˜ ์ˆœ์„œ ์žฌ๊ณ„์‚ฐ +DO $$ +DECLARE + tree_rec RECORD; +BEGIN + FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE + LOOP + PERFORM recalculate_canonical_orders(tree_rec.tree_id); + END LOOP; +END $$; diff --git a/backend/src/api/routes/memo_trees.py b/backend/src/api/routes/memo_trees.py new file mode 100644 index 0000000..7716bc9 --- /dev/null +++ b/backend/src/api/routes/memo_trees.py @@ -0,0 +1,700 @@ +""" +ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, func, and_, or_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +from uuid import UUID + +from ...core.database import get_db +from ...models.user import User +from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare +from ...schemas.memo_tree import ( + MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes, + MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove, + MemoTreeStats, MemoSearchRequest, MemoSearchResult +) +from ..dependencies import get_current_active_user + +router = APIRouter(prefix="/memo-trees", tags=["memo-trees"]) + + +# ============================================================================ +# ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ด€๋ฆฌ +# ============================================================================ + +@router.get("/", response_model=List[MemoTreeResponse]) +async def get_user_memo_trees( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), + include_archived: bool = Query(False, description="๋ณด๊ด€๋œ ํŠธ๋ฆฌ ํฌํ•จ ์—ฌ๋ถ€") +): + """์‚ฌ์šฉ์ž์˜ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ""" + try: + query = select(MemoTree).where(MemoTree.user_id == current_user.id) + + if not include_archived: + query = query.where(MemoTree.is_archived == False) + + query = query.order_by(MemoTree.updated_at.desc()) + + result = await db.execute(query) + trees = result.scalars().all() + + # ๊ฐ ํŠธ๋ฆฌ์˜ ๋…ธ๋“œ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + tree_responses = [] + for tree in trees: + node_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id) + ) + node_count = node_count_result.scalar() or 0 + + tree_dict = { + "id": str(tree.id), + "user_id": str(tree.user_id), + "title": tree.title, + "description": tree.description, + "tree_type": tree.tree_type, + "template_data": tree.template_data, + "settings": tree.settings, + "created_at": tree.created_at, + "updated_at": tree.updated_at, + "is_public": tree.is_public, + "is_archived": tree.is_archived, + "node_count": node_count + } + tree_responses.append(MemoTreeResponse(**tree_dict)) + + return tree_responses + + except Exception as e: + print(f"ERROR in get_user_memo_trees: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo trees: {str(e)}" + ) + + +@router.post("/", response_model=MemoTreeResponse) +async def create_memo_tree( + tree_data: MemoTreeCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ƒ์„ฑ""" + try: + new_tree = MemoTree( + user_id=current_user.id, + title=tree_data.title, + description=tree_data.description, + tree_type=tree_data.tree_type, + template_data=tree_data.template_data or {}, + settings=tree_data.settings or {}, + is_public=tree_data.is_public + ) + + db.add(new_tree) + await db.commit() + await db.refresh(new_tree) + + tree_dict = { + "id": str(new_tree.id), + "user_id": str(new_tree.user_id), + "title": new_tree.title, + "description": new_tree.description, + "tree_type": new_tree.tree_type, + "template_data": new_tree.template_data, + "settings": new_tree.settings, + "created_at": new_tree.created_at, + "updated_at": new_tree.updated_at, + "is_public": new_tree.is_public, + "is_archived": new_tree.is_archived, + "node_count": 0 + } + + return MemoTreeResponse(**tree_dict) + + except Exception as e: + await db.rollback() + print(f"ERROR in create_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create memo tree: {str(e)}" + ) + + +@router.get("/{tree_id}", response_model=MemoTreeResponse) +async def get_memo_tree( + tree_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ƒ์„ธ ์กฐํšŒ""" + try: + result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + or_( + MemoTree.user_id == current_user.id, + MemoTree.is_public == True + ) + ) + ) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ๋…ธ๋“œ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + node_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id) + ) + node_count = node_count_result.scalar() or 0 + + tree_dict = { + "id": str(tree.id), + "user_id": str(tree.user_id), + "title": tree.title, + "description": tree.description, + "tree_type": tree.tree_type, + "template_data": tree.template_data, + "settings": tree.settings, + "created_at": tree.created_at, + "updated_at": tree.updated_at, + "is_public": tree.is_public, + "is_archived": tree.is_archived, + "node_count": node_count + } + + return MemoTreeResponse(**tree_dict) + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo tree: {str(e)}" + ) + + +@router.put("/{tree_id}", response_model=MemoTreeResponse) +async def update_memo_tree( + tree_id: UUID, + tree_data: MemoTreeUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์—…๋ฐ์ดํŠธ""" + try: + result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + MemoTree.user_id == current_user.id + ) + ) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ ์šฉ + update_data = tree_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(tree, field, value) + + await db.commit() + await db.refresh(tree) + + # ๋…ธ๋“œ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + node_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id) + ) + node_count = node_count_result.scalar() or 0 + + tree_dict = { + "id": str(tree.id), + "user_id": str(tree.user_id), + "title": tree.title, + "description": tree.description, + "tree_type": tree.tree_type, + "template_data": tree.template_data, + "settings": tree.settings, + "created_at": tree.created_at, + "updated_at": tree.updated_at, + "is_public": tree.is_public, + "is_archived": tree.is_archived, + "node_count": node_count + } + + return MemoTreeResponse(**tree_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in update_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update memo tree: {str(e)}" + ) + + +@router.delete("/{tree_id}") +async def delete_memo_tree( + tree_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‚ญ์ œ""" + try: + result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + MemoTree.user_id == current_user.id + ) + ) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ํŠธ๋ฆฌ ์‚ญ์ œ (CASCADE๋กœ ๊ด€๋ จ ๋…ธ๋“œ๋“ค๋„ ์ž๋™ ์‚ญ์ œ๋จ) + await db.delete(tree) + await db.commit() + + return {"message": "Memo tree deleted successfully"} + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in delete_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete memo tree: {str(e)}" + ) + + +# ============================================================================ +# ๋ฉ”๋ชจ ๋…ธ๋“œ ๊ด€๋ฆฌ +# ============================================================================ + +@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse]) +async def get_memo_tree_nodes( + tree_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ์˜ ๋ชจ๋“  ๋…ธ๋“œ ์กฐํšŒ""" + try: + # ํŠธ๋ฆฌ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + tree_result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + or_( + MemoTree.user_id == current_user.id, + MemoTree.is_public == True + ) + ) + ) + ) + tree = tree_result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ๋…ธ๋“œ๋“ค ์กฐํšŒ + result = await db.execute( + select(MemoNode) + .where(MemoNode.tree_id == tree_id) + .order_by(MemoNode.path, MemoNode.sort_order) + ) + nodes = result.scalars().all() + + # ๊ฐ ๋…ธ๋“œ์˜ ์ž์‹ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + node_responses = [] + for node in nodes: + children_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id) + ) + children_count = children_count_result.scalar() or 0 + + node_dict = { + "id": str(node.id), + "tree_id": str(node.tree_id), + "parent_id": str(node.parent_id) if node.parent_id else None, + "user_id": str(node.user_id), + "title": node.title, + "content": node.content, + "node_type": node.node_type, + "sort_order": node.sort_order, + "depth_level": node.depth_level, + "path": node.path, + "tags": node.tags or [], + "node_metadata": node.node_metadata or {}, + "status": node.status, + "word_count": node.word_count, + "is_canonical": node.is_canonical, + "canonical_order": node.canonical_order, + "story_path": node.story_path, + "created_at": node.created_at, + "updated_at": node.updated_at, + "children_count": children_count + } + node_responses.append(MemoNodeResponse(**node_dict)) + + return node_responses + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_memo_tree_nodes: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo tree nodes: {str(e)}" + ) + + +@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse) +async def create_memo_node( + tree_id: UUID, + node_data: MemoNodeCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ฑ""" + try: + # ํŠธ๋ฆฌ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + tree_result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + MemoTree.user_id == current_user.id + ) + ) + ) + tree = tree_result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ๋ถ€๋ชจ ๋…ธ๋“œ ํ™•์ธ (์žˆ๋‹ค๋ฉด) + if node_data.parent_id: + parent_result = await db.execute( + select(MemoNode).where( + and_( + MemoNode.id == UUID(node_data.parent_id), + MemoNode.tree_id == tree_id + ) + ) + ) + parent_node = parent_result.scalar_one_or_none() + if not parent_node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent node not found" + ) + + # ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ + word_count = 0 + if node_data.content: + word_count = len(node_data.content.replace('\n', ' ').split()) + + new_node = MemoNode( + tree_id=tree_id, + parent_id=UUID(node_data.parent_id) if node_data.parent_id else None, + user_id=current_user.id, + title=node_data.title, + content=node_data.content, + node_type=node_data.node_type, + sort_order=node_data.sort_order, + tags=node_data.tags or [], + node_metadata=node_data.node_metadata or {}, + status=node_data.status, + word_count=word_count, + is_canonical=node_data.is_canonical or False + ) + + db.add(new_node) + await db.commit() + await db.refresh(new_node) + + node_dict = { + "id": str(new_node.id), + "tree_id": str(new_node.tree_id), + "parent_id": str(new_node.parent_id) if new_node.parent_id else None, + "user_id": str(new_node.user_id), + "title": new_node.title, + "content": new_node.content, + "node_type": new_node.node_type, + "sort_order": new_node.sort_order, + "depth_level": new_node.depth_level, + "path": new_node.path, + "tags": new_node.tags or [], + "node_metadata": new_node.node_metadata or {}, + "status": new_node.status, + "word_count": new_node.word_count, + "is_canonical": new_node.is_canonical, + "canonical_order": new_node.canonical_order, + "story_path": new_node.story_path, + "created_at": new_node.created_at, + "updated_at": new_node.updated_at, + "children_count": 0 + } + + return MemoNodeResponse(**node_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in create_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create memo node: {str(e)}" + ) + + +@router.get("/nodes/{node_id}", response_model=MemoNodeResponse) +async def get_memo_node( + node_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ธ ์กฐํšŒ""" + try: + result = await db.execute( + select(MemoNode) + .options(selectinload(MemoNode.tree)) + .where(MemoNode.id == node_id) + ) + node = result.scalar_one_or_none() + + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo node not found" + ) + + # ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if node.tree.user_id != current_user.id and not node.tree.is_public: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this node" + ) + + # ์ž์‹ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + children_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id) + ) + children_count = children_count_result.scalar() or 0 + + node_dict = { + "id": str(node.id), + "tree_id": str(node.tree_id), + "parent_id": str(node.parent_id) if node.parent_id else None, + "user_id": str(node.user_id), + "title": node.title, + "content": node.content, + "node_type": node.node_type, + "sort_order": node.sort_order, + "depth_level": node.depth_level, + "path": node.path, + "tags": node.tags or [], + "node_metadata": node.node_metadata or {}, + "status": node.status, + "word_count": node.word_count, + "is_canonical": node.is_canonical, + "canonical_order": node.canonical_order, + "story_path": node.story_path, + "created_at": node.created_at, + "updated_at": node.updated_at, + "children_count": children_count + } + + return MemoNodeResponse(**node_dict) + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo node: {str(e)}" + ) + + +@router.put("/nodes/{node_id}", response_model=MemoNodeResponse) +async def update_memo_node( + node_id: UUID, + node_data: MemoNodeUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์—…๋ฐ์ดํŠธ""" + try: + result = await db.execute( + select(MemoNode) + .options(selectinload(MemoNode.tree)) + .where(MemoNode.id == node_id) + ) + node = result.scalar_one_or_none() + + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo node not found" + ) + + # ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ (์†Œ์œ ์ž๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ) + if node.tree.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this node" + ) + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ ์šฉ + update_data = node_data.dict(exclude_unset=True) + for field, value in update_data.items(): + if field == "parent_id" and value: + # ๋ถ€๋ชจ ๋…ธ๋“œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + parent_result = await db.execute( + select(MemoNode).where( + and_( + MemoNode.id == UUID(value), + MemoNode.tree_id == node.tree_id + ) + ) + ) + parent_node = parent_result.scalar_one_or_none() + if not parent_node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent node not found" + ) + setattr(node, field, UUID(value)) + elif field == "parent_id" and value is None: + setattr(node, field, None) + else: + setattr(node, field, value) + + # ๋‚ด์šฉ์ด ์—…๋ฐ์ดํŠธ๋˜๋ฉด ๋‹จ์–ด ์ˆ˜ ์žฌ๊ณ„์‚ฐ + if "content" in update_data: + word_count = 0 + if node.content: + word_count = len(node.content.replace('\n', ' ').split()) + node.word_count = word_count + + await db.commit() + await db.refresh(node) + + # ์ž์‹ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + children_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id) + ) + children_count = children_count_result.scalar() or 0 + + node_dict = { + "id": str(node.id), + "tree_id": str(node.tree_id), + "parent_id": str(node.parent_id) if node.parent_id else None, + "user_id": str(node.user_id), + "title": node.title, + "content": node.content, + "node_type": node.node_type, + "sort_order": node.sort_order, + "depth_level": node.depth_level, + "path": node.path, + "tags": node.tags or [], + "node_metadata": node.node_metadata or {}, + "status": node.status, + "word_count": node.word_count, + "is_canonical": node.is_canonical, + "canonical_order": node.canonical_order, + "story_path": node.story_path, + "created_at": node.created_at, + "updated_at": node.updated_at, + "children_count": children_count + } + + return MemoNodeResponse(**node_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in update_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update memo node: {str(e)}" + ) + + +@router.delete("/nodes/{node_id}") +async def delete_memo_node( + node_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์‚ญ์ œ""" + try: + result = await db.execute( + select(MemoNode) + .options(selectinload(MemoNode.tree)) + .where(MemoNode.id == node_id) + ) + node = result.scalar_one_or_none() + + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo node not found" + ) + + # ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ (์†Œ์œ ์ž๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ) + if node.tree.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to delete this node" + ) + + # ๋…ธ๋“œ ์‚ญ์ œ (CASCADE๋กœ ์ž์‹ ๋…ธ๋“œ๋“ค๋„ ์ž๋™ ์‚ญ์ œ๋จ) + await db.delete(node) + await db.commit() + + return {"message": "Memo node deleted successfully"} + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in delete_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete memo node: {str(e)}" + ) diff --git a/backend/src/main.py b/backend/src/main.py index 15fed77..102571b 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -9,7 +9,7 @@ import uvicorn from .core.config import settings from .core.database import init_db -from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories +from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees @asynccontextmanager @@ -51,6 +51,7 @@ app.include_router(books.router, prefix="/api/books", tags=["์„œ์ "]) app.include_router(book_categories.router, prefix="/api/book-categories", tags=["์„œ์  ์†Œ๋ถ„๋ฅ˜"]) app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["์ฑ…๊ฐˆํ”ผ"]) app.include_router(search.router, prefix="/api/search", tags=["๊ฒ€์ƒ‰"]) +app.include_router(memo_trees.router, prefix="/api", tags=["ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ"]) @app.get("/") diff --git a/backend/src/models/memo_tree.py b/backend/src/models/memo_tree.py new file mode 100644 index 0000000..04c323a --- /dev/null +++ b/backend/src/models/memo_tree.py @@ -0,0 +1,111 @@ +""" +ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class MemoTree(Base): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ (ํ”„๋กœ์ ํŠธ/์›Œํฌ์ŠคํŽ˜์ด์Šค)""" + __tablename__ = "memo_trees" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + title = Column(String(255), nullable=False) + description = Column(Text) + tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general' + template_data = Column(JSON) # ํ…œํ”Œ๋ฆฟ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + settings = Column(JSON, default={}) # ํŠธ๋ฆฌ๋ณ„ ์„ค์ • + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + is_public = Column(Boolean, default=False) + is_archived = Column(Boolean, default=False) + + # ๊ด€๊ณ„ + user = relationship("User", back_populates="memo_trees") + nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan") + shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan") + + +class MemoNode(Base): + """๋ฉ”๋ชจ ๋…ธ๋“œ (ํŠธ๋ฆฌ์˜ ๊ฐ ๋…ธ๋“œ)""" + __tablename__ = "memo_nodes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False) + parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE")) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # ๊ธฐ๋ณธ ์ •๋ณด + title = Column(String(500), nullable=False) + content = Column(Text) # Markdown ํ˜•์‹ + node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot' + + # ํŠธ๋ฆฌ ๊ตฌ์กฐ ๊ด€๋ฆฌ + sort_order = Column(Integer, default=0) + depth_level = Column(Integer, default=0) + path = Column(Text) # ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + tags = Column(ARRAY(String)) # ํƒœ๊ทธ ๋ฐฐ์—ด + node_metadata = Column(JSON, default={}) # ๋…ธ๋“œ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + + # ์ƒํƒœ ๊ด€๋ฆฌ + status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete' + word_count = Column(Integer, default=0) + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical = Column(Boolean, default=False) # ์ •์‚ฌ ๊ฒฝ๋กœ ์—ฌ๋ถ€ + canonical_order = Column(Integer, nullable=True) # ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ + story_path = Column(Text, nullable=True) # ์ •์‚ฌ ๊ฒฝ๋กœ ๋ฌธ์ž์—ด + + # ์‹œ๊ฐ„ ์ •๋ณด + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # ๊ด€๊ณ„ + tree = relationship("MemoTree", back_populates="nodes") + user = relationship("User", back_populates="memo_nodes") + parent = relationship("MemoNode", remote_side=[id], back_populates="children") + children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan") + versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan") + + +class MemoNodeVersion(Base): + """๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ๊ด€๋ฆฌ""" + __tablename__ = "memo_node_versions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False) + version_number = Column(Integer, nullable=False) + title = Column(String(500), nullable=False) + content = Column(Text) + node_metadata = Column(JSON, default={}) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # ๊ด€๊ณ„ + node = relationship("MemoNode", back_populates="versions") + creator = relationship("User") + + +class MemoTreeShare(Base): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  (ํ˜‘์—… ๊ธฐ๋Šฅ)""" + __tablename__ = "memo_tree_shares" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False) + shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + permission_level = Column(String(20), default="read") # 'read', 'write', 'admin' + created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # ๊ด€๊ณ„ + tree = relationship("MemoTree", back_populates="shares") + shared_with_user = relationship("User", foreign_keys=[shared_with_user_id]) + creator = relationship("User", foreign_keys=[created_by]) diff --git a/backend/src/models/user.py b/backend/src/models/user.py index bfc408c..b87b08e 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -3,6 +3,7 @@ """ from sqlalchemy import Column, String, Boolean, DateTime, Text from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from sqlalchemy.sql import func import uuid @@ -30,5 +31,9 @@ class User(Base): language = Column(String(10), default="ko") # ko, en timezone = Column(String(50), default="Asia/Seoul") + # ๊ด€๊ณ„ (lazy loading์„ ์œ„ํ•ด ๋ฌธ์ž์—ด๋กœ ์ฐธ์กฐ) + memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic") + memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic") + def __repr__(self): return f"" diff --git a/backend/src/schemas/memo_tree.py b/backend/src/schemas/memo_tree.py new file mode 100644 index 0000000..9c8731b --- /dev/null +++ b/backend/src/schemas/memo_tree.py @@ -0,0 +1,205 @@ +""" +ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ Pydantic ์Šคํ‚ค๋งˆ +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime +from uuid import UUID + + +# ๊ธฐ๋ณธ ์Šคํ‚ค๋งˆ๋“ค +class MemoTreeBase(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ธฐ๋ณธ ์Šคํ‚ค๋งˆ""" + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$") + template_data: Optional[Dict[str, Any]] = None + settings: Optional[Dict[str, Any]] = None + is_public: bool = False + + +class MemoTreeCreate(MemoTreeBase): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ƒ์„ฑ ์š”์ฒญ""" + pass + + +class MemoTreeUpdate(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$") + template_data: Optional[Dict[str, Any]] = None + settings: Optional[Dict[str, Any]] = None + is_public: Optional[bool] = None + is_archived: Optional[bool] = None + + +class MemoTreeResponse(MemoTreeBase): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‘๋‹ต""" + id: str + user_id: str + created_at: datetime + updated_at: Optional[datetime] + is_archived: bool + node_count: Optional[int] = 0 # ๋…ธ๋“œ ๊ฐœ์ˆ˜ + + class Config: + from_attributes = True + + +# ๋ฉ”๋ชจ ๋…ธ๋“œ ์Šคํ‚ค๋งˆ๋“ค +class MemoNodeBase(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ๊ธฐ๋ณธ ์Šคํ‚ค๋งˆ""" + title: str = Field(..., min_length=1, max_length=500) + content: Optional[str] = None + node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$") + tags: Optional[List[str]] = None + node_metadata: Optional[Dict[str, Any]] = None + status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$") + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical: Optional[bool] = False + canonical_order: Optional[int] = None + + +class MemoNodeCreate(MemoNodeBase): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ฑ ์š”์ฒญ""" + tree_id: str + parent_id: Optional[str] = None + sort_order: Optional[int] = 0 + + +class MemoNodeUpdate(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = Field(None, min_length=1, max_length=500) + content: Optional[str] = None + node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$") + parent_id: Optional[str] = None + sort_order: Optional[int] = None + tags: Optional[List[str]] = None + node_metadata: Optional[Dict[str, Any]] = None + status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$") + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical: Optional[bool] = None + canonical_order: Optional[int] = None + + +class MemoNodeMove(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์ด๋™ ์š”์ฒญ""" + parent_id: Optional[str] = None + sort_order: int = 0 + + +class MemoNodeResponse(MemoNodeBase): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์‘๋‹ต""" + id: str + tree_id: str + parent_id: Optional[str] + user_id: str + sort_order: int + depth_level: int + path: Optional[str] + word_count: int + created_at: datetime + updated_at: Optional[datetime] + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical: bool + canonical_order: Optional[int] + story_path: Optional[str] + + # ๊ด€๊ณ„ ๋ฐ์ดํ„ฐ + children_count: Optional[int] = 0 + + class Config: + from_attributes = True + + +# ํŠธ๋ฆฌ ๊ตฌ์กฐ ์‘๋‹ต +class MemoTreeWithNodes(MemoTreeResponse): + """๋…ธ๋“œ๊ฐ€ ํฌํ•จ๋œ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‘๋‹ต""" + nodes: List[MemoNodeResponse] = [] + + +# ๋…ธ๋“œ ๋ฒ„์ „ ์Šคํ‚ค๋งˆ๋“ค +class MemoNodeVersionResponse(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ์‘๋‹ต""" + id: str + node_id: str + version_number: int + title: str + content: Optional[str] + node_metadata: Optional[Dict[str, Any]] + created_at: datetime + created_by: str + + class Config: + from_attributes = True + + +# ๊ณต์œ  ์Šคํ‚ค๋งˆ๋“ค +class MemoTreeShareCreate(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  ์ƒ์„ฑ ์š”์ฒญ""" + shared_with_user_email: str + permission_level: str = Field(default="read", pattern="^(read|write|admin)$") + + +class MemoTreeShareResponse(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  ์‘๋‹ต""" + id: str + tree_id: str + shared_with_user_id: str + shared_with_user_email: str + shared_with_user_name: str + permission_level: str + created_at: datetime + created_by: str + + class Config: + from_attributes = True + + +# ๊ฒ€์ƒ‰ ๋ฐ ํ•„ํ„ฐ๋ง +class MemoSearchRequest(BaseModel): + """๋ฉ”๋ชจ ๊ฒ€์ƒ‰ ์š”์ฒญ""" + query: str = Field(..., min_length=1) + tree_id: Optional[str] = None + node_types: Optional[List[str]] = None + tags: Optional[List[str]] = None + status: Optional[List[str]] = None + + +class MemoSearchResult(BaseModel): + """๋ฉ”๋ชจ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ""" + node: MemoNodeResponse + tree: MemoTreeResponse + matches: List[Dict[str, Any]] # ๋งค์น˜๋œ ๋ถ€๋ถ„๋“ค + relevance_score: float + + +# ํ†ต๊ณ„ ์Šคํ‚ค๋งˆ +class MemoTreeStats(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ํ†ต๊ณ„""" + total_nodes: int + nodes_by_type: Dict[str, int] + nodes_by_status: Dict[str, int] + total_words: int + last_updated: Optional[datetime] + + +# ๋‚ด๋ณด๋‚ด๊ธฐ ์Šคํ‚ค๋งˆ +class ExportRequest(BaseModel): + """๋‚ด๋ณด๋‚ด๊ธฐ ์š”์ฒญ""" + tree_id: str + format: str = Field(..., pattern="^(markdown|html|pdf|docx)$") + include_metadata: bool = True + node_types: Optional[List[str]] = None + + +class ExportResponse(BaseModel): + """๋‚ด๋ณด๋‚ด๊ธฐ ์‘๋‹ต""" + file_url: str + file_name: str + file_size: int + created_at: datetime diff --git a/database/init/005_create_memo_tree_tables.sql b/database/init/005_create_memo_tree_tables.sql new file mode 100644 index 0000000..d7a011f --- /dev/null +++ b/database/init/005_create_memo_tree_tables.sql @@ -0,0 +1,153 @@ +-- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- 005_create_memo_tree_tables.sql + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ (ํ”„๋กœ์ ํŠธ/์›Œํฌ์ŠคํŽ˜์ด์Šค) +CREATE TABLE memo_trees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general' + template_data JSONB, -- ํ…œํ”Œ๋ฆฟ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + settings JSONB DEFAULT '{}', -- ํŠธ๋ฆฌ๋ณ„ ์„ค์ • + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_public BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ (ํŠธ๋ฆฌ์˜ ๊ฐ ๋…ธ๋“œ) +CREATE TABLE memo_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- ๊ธฐ๋ณธ ์ •๋ณด + title VARCHAR(500) NOT NULL, + content TEXT, -- ์‹ค์ œ ๋ฉ”๋ชจ ๋‚ด์šฉ (Markdown) + node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot' + + -- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๊ด€๋ฆฌ + sort_order INTEGER DEFAULT 0, + depth_level INTEGER DEFAULT 0, + path TEXT, -- ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + + -- ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + tags TEXT[], -- ํƒœ๊ทธ ๋ฐฐ์—ด + node_metadata JSONB DEFAULT '{}', -- ๋…ธ๋“œ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์บ๋ฆญํ„ฐ ์ •๋ณด, ํ”Œ๋กฏ ์ •๋ณด ๋“ฑ) + + -- ์ƒํƒœ ๊ด€๋ฆฌ + status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete' + word_count INTEGER DEFAULT 0, + + -- ์‹œ๊ฐ„ ์ •๋ณด + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- ์ œ์•ฝ ์กฐ๊ฑด + CONSTRAINT no_self_reference CHECK (id != parent_id) +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ๊ด€๋ฆฌ (์„ ํƒ์ ) +CREATE TABLE memo_node_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + node_metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(node_id, version_number) +); + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  (ํ˜‘์—… ๊ธฐ๋Šฅ) +CREATE TABLE memo_tree_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin' + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(tree_id, shared_with_user_id) +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id); +CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type); +CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id); +CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id); +CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id); +CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/')); +CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags); +CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type); +CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id); +CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +CREATE TRIGGER memo_trees_updated_at + BEFORE UPDATE ON memo_trees + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +CREATE TRIGGER memo_nodes_updated_at + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: ๊ฒฝ๋กœ ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_node_path() +RETURNS TRIGGER AS $$ +BEGIN + -- ๋ฃจํŠธ ๋…ธ๋“œ์ธ ๊ฒฝ์šฐ + IF NEW.parent_id IS NULL THEN + NEW.path = '/' || NEW.id::text; + NEW.depth_level = 0; + ELSE + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ๊ฒฝ๋กœ๋ฅผ ๊ฐ€์ ธ์™€์„œ ํ™•์žฅ + SELECT path || '/' || NEW.id::text, depth_level + 1 + INTO NEW.path, NEW.depth_level + FROM memo_nodes + WHERE id = NEW.parent_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ +CREATE TRIGGER memo_nodes_path_update + BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_node_path(); + +-- ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ (๊ฐœ๋ฐœ์šฉ) +-- ์†Œ์„ค ํ…œํ”Œ๋ฆฟ ์˜ˆ์‹œ +INSERT INTO memo_trees (user_id, title, description, tree_type, template_data) +SELECT + u.id, + '๋‚ด ์ฒซ ๋ฒˆ์งธ ์†Œ์„ค', + 'ํŒํƒ€์ง€ ์†Œ์„ค ํ”„๋กœ์ ํŠธ', + 'novel', + '{ + "genre": "fantasy", + "target_length": 100000, + "chapters_planned": 20, + "main_characters": [], + "world_building": {} + }'::jsonb +FROM users u +WHERE u.email = 'admin@test.com' +LIMIT 1; diff --git a/frontend/hierarchy.html b/frontend/hierarchy.html index 9eb22b7..97c9f00 100644 --- a/frontend/hierarchy.html +++ b/frontend/hierarchy.html @@ -109,8 +109,9 @@

๐Ÿ“š ๋ฌธ์„œ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ

- ๊ทธ๋ฆฌ๋“œ ๋ทฐ - ๊ณ„์ธต๊ตฌ์กฐ ๋ทฐ + ๐Ÿ“– ๊ทธ๋ฆฌ๋“œ ๋ทฐ + ๐Ÿ“š ๊ณ„์ธต๊ตฌ์กฐ ๋ทฐ + ๐ŸŒณ ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ
diff --git a/frontend/index.html b/frontend/index.html index a0d8bb9..d344f68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -59,8 +59,9 @@ Document Server diff --git a/frontend/memo-tree.html b/frontend/memo-tree.html new file mode 100644 index 0000000..b289f8c --- /dev/null +++ b/frontend/memo-tree.html @@ -0,0 +1,651 @@ + + + + + + ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ - Document Server + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +

ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ

+
+ + + +
+ + +
+ +
+ + + +
+ + +
+ + +
+ + + +
+
+
+
+ + +
+ +
+
+ +
+ + + +
+ + +
+ + + + +
+
+
+ + +
+ +
+ +
+
+ +

ํŠธ๋ฆฌ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+

์™ผ์ชฝ์—์„œ ํŠธ๋ฆฌ๋ฅผ ์„ ํƒํ•˜๊ฑฐ๋‚˜ ์ƒˆ๋กœ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”

+
+
+ + +
+
+ +

์ฒซ ๋ฒˆ์งธ ๋…ธ๋“œ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”

+ +
+
+ + +
+ +
+ + + + + + +
+ + +
+
+
+
+ + +
+ + + + +
+
+
+ + +
+
+ +

๋…ธ๋“œ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+

์™ผ์ชฝ ํŠธ๋ฆฌ์—์„œ ๋…ธ๋“œ๋ฅผ ํด๋ฆญํ•˜์—ฌ ํŽธ์ง‘์„ ์‹œ์ž‘ํ•˜์„ธ์š”

+
+
+
+
+ + +
+
+ +

๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค

+

ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”

+ +
+
+ + +
+
+

์ƒˆ ํŠธ๋ฆฌ ์ƒ์„ฑ

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

๋กœ๊ทธ์ธ

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + + + + diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index 065ff93..8228d22 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -119,7 +119,32 @@ class DocumentServerAPI { // ์ธ์ฆ ๊ด€๋ จ API async login(email, password) { - return await this.post('/auth/login', { email, password }); + const response = await this.post('/auth/login', { email, password }); + + // ํ† ํฐ ์ €์žฅ + if (response.access_token) { + this.setToken(response.access_token); + + // ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + try { + const user = await this.getCurrentUser(); + return { + success: true, + user: user, + token: response.access_token + }; + } catch (error) { + return { + success: false, + message: '์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' + }; + } + } else { + return { + success: false, + message: '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.' + }; + } } async logout() { @@ -414,6 +439,73 @@ class DocumentServerAPI { async deleteNote(noteId) { return await this.delete(`/notes/${noteId}`); } + + // ============================================================================ + // ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ API + // ============================================================================ + + // ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ด€๋ฆฌ + async getUserMemoTrees(includeArchived = false) { + const params = includeArchived ? '?include_archived=true' : ''; + return await this.get(`/memo-trees/${params}`); + } + + async createMemoTree(treeData) { + return await this.post('/memo-trees/', treeData); + } + + async getMemoTree(treeId) { + return await this.get(`/memo-trees/${treeId}`); + } + + async updateMemoTree(treeId, treeData) { + return await this.put(`/memo-trees/${treeId}`, treeData); + } + + async deleteMemoTree(treeId) { + return await this.delete(`/memo-trees/${treeId}`); + } + + // ๋ฉ”๋ชจ ๋…ธ๋“œ ๊ด€๋ฆฌ + async getMemoTreeNodes(treeId) { + return await this.get(`/memo-trees/${treeId}/nodes`); + } + + async createMemoNode(nodeData) { + return await this.post(`/memo-trees/${nodeData.tree_id}/nodes`, nodeData); + } + + async getMemoNode(nodeId) { + return await this.get(`/memo-trees/nodes/${nodeId}`); + } + + async updateMemoNode(nodeId, nodeData) { + return await this.put(`/memo-trees/nodes/${nodeId}`, nodeData); + } + + async deleteMemoNode(nodeId) { + return await this.delete(`/memo-trees/nodes/${nodeId}`); + } + + // ๋…ธ๋“œ ์ด๋™ + async moveMemoNode(nodeId, moveData) { + return await this.put(`/memo-trees/nodes/${nodeId}/move`, moveData); + } + + // ํŠธ๋ฆฌ ํ†ต๊ณ„ + async getMemoTreeStats(treeId) { + return await this.get(`/memo-trees/${treeId}/stats`); + } + + // ๊ฒ€์ƒ‰ + async searchMemoNodes(searchData) { + return await this.post('/memo-trees/search', searchData); + } + + // ๋‚ด๋ณด๋‚ด๊ธฐ + async exportMemoTree(exportData) { + return await this.post('/memo-trees/export', exportData); + } } // ์ „์—ญ API ์ธ์Šคํ„ด์Šค diff --git a/frontend/static/js/memo-tree.js b/frontend/static/js/memo-tree.js new file mode 100644 index 0000000..21ee8ec --- /dev/null +++ b/frontend/static/js/memo-tree.js @@ -0,0 +1,984 @@ +/** + * ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ JavaScript + */ + +// Monaco Editor ์ธ์Šคํ„ด์Šค +let monacoEditor = null; + +// ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ Alpine.js ์ปดํฌ๋„ŒํŠธ +window.memoTreeApp = function() { + return { + // ์ƒํƒœ ๊ด€๋ฆฌ + currentUser: null, + userTrees: [], + selectedTreeId: '', + selectedTree: null, + treeNodes: [], + selectedNode: null, + + // UI ์ƒํƒœ + showNewTreeModal: false, + showNewNodeModal: false, + showTreeSettings: false, + showLoginModal: false, + + // ๋กœ๊ทธ์ธ ํผ ์ƒํƒœ + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // ํผ ๋ฐ์ดํ„ฐ + newTree: { + title: '', + description: '', + tree_type: 'general' + }, + + newNode: { + title: '', + node_type: 'memo', + parent_id: null + }, + + // ์—๋””ํ„ฐ ์ƒํƒœ + editorContent: '', + isEditorDirty: false, + + // ํŠธ๋ฆฌ ์ƒํƒœ + expandedNodes: new Set(), + + // ํŠธ๋ฆฌ ๋‹ค์ด์–ด๊ทธ๋žจ ์ƒํƒœ + treeZoom: 1, + treePanX: 0, + treePanY: 0, + nodePositions: new Map(), // ๋…ธ๋“œ ID -> {x, y} ์œ„์น˜ ๋งคํ•‘ + isDragging: false, + dragNode: null, + dragOffset: { x: 0, y: 0 }, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐ŸŒณ ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ ์ดˆ๊ธฐํ™” ์ค‘...'); + + // API ๊ฐ์ฒด๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ (๋” ๊ธด ์‹œ๊ฐ„) + let retries = 0; + while ((!window.api || typeof window.api.getUserMemoTrees !== 'function') && retries < 50) { + console.log(`โณ API ๊ฐ์ฒด ๋กœ๋”ฉ ๋Œ€๊ธฐ ์ค‘... (${retries + 1}/50)`); + await new Promise(resolve => setTimeout(resolve, 100)); + retries++; + } + + if (!window.api || typeof window.api.getUserMemoTrees !== 'function') { + console.error('โŒ API ๊ฐ์ฒด ๋˜๋Š” getUserMemoTrees ํ•จ์ˆ˜๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + console.log('ํ˜„์žฌ window.api:', window.api); + if (window.api) { + console.log('API ๊ฐ์ฒด์˜ ๋ฉ”์„œ๋“œ๋“ค:', Object.getOwnPropertyNames(window.api)); + } + return; + } + + try { + await this.checkAuthStatus(); + if (this.currentUser) { + await this.loadUserTrees(); + await this.initMonacoEditor(); + } + } catch (error) { + console.error('โŒ ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.currentUser = user; + console.log('โœ… ์‚ฌ์šฉ์ž ์ธ์ฆ๋จ:', user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ:', error.message); + this.currentUser = null; + + // ํ† ํฐ์ด ์žˆ์ง€๋งŒ ๋งŒ๋ฃŒ๋œ ๊ฒฝ์šฐ ์ œ๊ฑฐ + if (localStorage.getItem('access_token')) { + console.log('๐Ÿ—‘๏ธ ๋งŒ๋ฃŒ๋œ ํ† ํฐ ์ œ๊ฑฐ'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.api.clearToken(); + } + } + }, + + // ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + async loadUserTrees() { + try { + console.log('๐Ÿ“Š ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋”ฉ...'); + const trees = await window.api.getUserMemoTrees(); + this.userTrees = trees || []; + console.log(`โœ… ${this.userTrees.length}๊ฐœ ํŠธ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ`); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + this.userTrees = []; + } + }, + + // ํŠธ๋ฆฌ ๋กœ๋“œ + async loadTree(treeId) { + if (!treeId) { + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + return; + } + + try { + console.log('๐ŸŒณ ํŠธ๋ฆฌ ๋กœ๋”ฉ:', treeId); + + // ํŠธ๋ฆฌ ์ •๋ณด ๋กœ๋“œ + const tree = this.userTrees.find(t => t.id === treeId); + this.selectedTree = tree; + + // ํŠธ๋ฆฌ ๋…ธ๋“œ๋“ค ๋กœ๋“œ + const nodes = await window.api.getMemoTreeNodes(treeId); + this.treeNodes = nodes || []; + + // ์ฒซ ๋ฒˆ์งธ ๋…ธ๋“œ ์„ ํƒ (์žˆ๋‹ค๋ฉด) + if (this.treeNodes.length > 0) { + this.selectNode(this.treeNodes[0]); + } + + // ํŠธ๋ฆฌ ๋‹ค์ด์–ด๊ทธ๋žจ ์œ„์น˜ ๊ณ„์‚ฐ + this.$nextTick(() => { + setTimeout(() => { + this.calculateNodePositions(); + }, 100); + }); + + console.log(`โœ… ํŠธ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ: ${this.treeNodes.length}๊ฐœ ๋…ธ๋“œ`); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ๋กœ๋“œ ์‹คํŒจ:', error); + alert('ํŠธ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ํŠธ๋ฆฌ ์ƒ์„ฑ + async createTree() { + try { + console.log('๐ŸŒณ ์ƒˆ ํŠธ๋ฆฌ ์ƒ์„ฑ:', this.newTree); + + const tree = await window.api.createMemoTree(this.newTree); + + // ํŠธ๋ฆฌ ๋ชฉ๋ก์— ์ถ”๊ฐ€ + this.userTrees.push(tree); + + // ์ƒˆ ํŠธ๋ฆฌ ์„ ํƒ + this.selectedTreeId = tree.id; + await this.loadTree(tree.id); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ ๋ฐ ํผ ๋ฆฌ์…‹ + this.showNewTreeModal = false; + this.newTree = { title: '', description: '', tree_type: 'general' }; + + console.log('โœ… ํŠธ๋ฆฌ ์ƒ์„ฑ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('ํŠธ๋ฆฌ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋ฃจํŠธ ๋…ธ๋“œ ์ƒ์„ฑ + async createRootNode() { + if (!this.selectedTree) return; + + try { + const nodeData = { + tree_id: this.selectedTree.id, + title: '์ƒˆ ๋…ธ๋“œ', + node_type: 'memo', + parent_id: null + }; + + const node = await window.api.createMemoNode(nodeData); + this.treeNodes.push(node); + this.selectNode(node); + + console.log('โœ… ๋ฃจํŠธ ๋…ธ๋“œ ์ƒ์„ฑ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋ฃจํŠธ ๋…ธ๋“œ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋…ธ๋“œ ์„ ํƒ + selectNode(node) { + // ์ด์ „ ๋…ธ๋“œ ์ €์žฅ + if (this.selectedNode && this.isEditorDirty) { + this.saveNode(); + } + + this.selectedNode = node; + + // ์—๋””ํ„ฐ์— ๋‚ด์šฉ ๋กœ๋“œ + if (monacoEditor && node.content) { + monacoEditor.setValue(node.content || ''); + this.isEditorDirty = false; + } + + console.log('๐Ÿ“ ๋…ธ๋“œ ์„ ํƒ:', node.title); + }, + + // ๋…ธ๋“œ ์ €์žฅ + async saveNode() { + if (!this.selectedNode) return; + + try { + // ์—๋””ํ„ฐ ๋‚ด์šฉ ๊ฐ€์ ธ์˜ค๊ธฐ + if (monacoEditor) { + this.selectedNode.content = monacoEditor.getValue(); + + // ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ (๊ฐ„๋‹จํ•œ ๋ฐฉ์‹) + const wordCount = this.selectedNode.content + .replace(/\s+/g, ' ') + .trim() + .split(' ') + .filter(word => word.length > 0).length; + this.selectedNode.word_count = wordCount; + } + + await window.api.updateMemoNode(this.selectedNode.id, this.selectedNode); + this.isEditorDirty = false; + + console.log('โœ… ๋…ธ๋“œ ์ €์žฅ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋…ธ๋“œ ์ €์žฅ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋…ธ๋“œ ์ œ๋ชฉ ์ €์žฅ + async saveNodeTitle() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // ๋…ธ๋“œ ํƒ€์ž… ์ €์žฅ + async saveNodeType() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // ๋…ธ๋“œ ์ƒํƒœ ์ €์žฅ + async saveNodeStatus() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // ๋…ธ๋“œ ์‚ญ์ œ + async deleteNode(nodeId) { + if (!confirm('์ •๋ง๋กœ ์ด ๋…ธ๋“œ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) return; + + try { + await window.api.deleteMemoNode(nodeId); + + // ํŠธ๋ฆฌ์—์„œ ์ œ๊ฑฐ + this.treeNodes = this.treeNodes.filter(node => node.id !== nodeId); + + // ์„ ํƒ๋œ ๋…ธ๋“œ์˜€๋‹ค๋ฉด ์„ ํƒ ํ•ด์ œ + if (this.selectedNode && this.selectedNode.id === nodeId) { + this.selectedNode = null; + if (monacoEditor) { + monacoEditor.setValue(''); + } + } + + console.log('โœ… ๋…ธ๋“œ ์‚ญ์ œ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋…ธ๋“œ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + + + // ์ž์‹ ๋…ธ๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ + getChildNodes(parentId) { + return this.treeNodes + .filter(node => node.parent_id === parentId) + .sort((a, b) => a.sort_order - b.sort_order); + }, + + // ๋ฃจํŠธ ๋…ธ๋“œ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ + get rootNodes() { + return this.treeNodes + .filter(node => !node.parent_id) + .sort((a, b) => a.sort_order - b.sort_order); + }, + + // ๋…ธ๋“œ ํƒ€์ž…๋ณ„ ์•„์ด์ฝ˜ ๊ฐ€์ ธ์˜ค๊ธฐ + getNodeIcon(nodeType) { + const icons = { + folder: '๐Ÿ“', + memo: '๐Ÿ“', + chapter: '๐Ÿ“–', + character: '๐Ÿ‘ค', + plot: '๐Ÿ“‹' + }; + return icons[nodeType] || '๐Ÿ“'; + }, + + // ์ƒํƒœ๋ณ„ ์ƒ‰์ƒ ํด๋ž˜์Šค ๊ฐ€์ ธ์˜ค๊ธฐ + getStatusColor(status) { + const colors = { + draft: 'text-gray-500', + writing: 'text-yellow-600', + review: 'text-blue-600', + complete: 'text-green-600' + }; + return colors[status] || 'text-gray-700'; + }, + + // ํŠธ๋ฆฌ ํƒ€์ž…๋ณ„ ์•„์ด์ฝ˜ ๊ฐ€์ ธ์˜ค๊ธฐ + getTreeIcon(treeType) { + const icons = { + novel: '๐Ÿ“š', + research: '๐Ÿ”ฌ', + project: '๐Ÿ’ผ', + general: '๐Ÿ“‚' + }; + return icons[treeType] || '๐Ÿ“‚'; + }, + + // ๋น ๋ฅธ ์ž์‹ ๋…ธ๋“œ ์ถ”๊ฐ€ + async addChildNode(parentNode) { + if (!this.selectedTree) return; + + try { + const nodeData = { + tree_id: this.selectedTree.id, + title: '์ƒˆ ๋…ธ๋“œ', + node_type: 'memo', + parent_id: parentNode.id + }; + + const node = await window.api.createMemoNode(nodeData); + this.treeNodes.push(node); + + // ๋ถ€๋ชจ ๋…ธ๋“œ ํŽผ์น˜๊ธฐ + this.expandedNodes.add(parentNode.id); + + // ์ƒˆ ๋…ธ๋“œ ์„ ํƒ + this.selectNode(node); + + console.log('โœ… ์ž์‹ ๋…ธ๋“œ ์ƒ์„ฑ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ์ž์‹ ๋…ธ๋“œ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ํ‘œ์‹œ + showContextMenu(event, node) { + // TODO: ์šฐํด๋ฆญ ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ๊ตฌํ˜„ + console.log('์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด:', node.title); + }, + + // ๋…ธ๋“œ ๋ฉ”๋‰ด ํ‘œ์‹œ + showNodeMenu(event, node) { + // TODO: ๋…ธ๋“œ ์˜ต์…˜ ๋ฉ”๋‰ด ๊ตฌํ˜„ + console.log('๋…ธ๋“œ ๋ฉ”๋‰ด:', node.title); + }, + + // ์ •์‚ฌ ๊ฒฝ๋กœ ํ† ๊ธ€ + async toggleCanonical(node) { + try { + const newCanonicalState = !node.is_canonical; + console.log(`๐ŸŒŸ ์ •์‚ฌ ๊ฒฝ๋กœ ํ† ๊ธ€: ${node.title} (${newCanonicalState ? '์„ค์ •' : 'ํ•ด์ œ'})`); + + const updatedData = { + is_canonical: newCanonicalState + }; + + const updatedNode = await window.api.updateMemoNode(node.id, updatedData); + + // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ (Alpine.js ๋ฐ˜์‘์„ฑ ๋ณด์žฅ) + const nodeIndex = this.treeNodes.findIndex(n => n.id === node.id); + if (nodeIndex !== -1) { + // ๋ฐฐ์—ด ์ „์ฒด๋ฅผ ์ƒˆ๋กœ ์ƒ์„ฑํ•˜์—ฌ Alpine.js ๋ฐ˜์‘์„ฑ ํŠธ๋ฆฌ๊ฑฐ + this.treeNodes = this.treeNodes.map(n => + n.id === node.id ? { ...n, ...updatedNode } : n + ); + } + + // ์„ ํƒ๋œ ๋…ธ๋“œ๋„ ์—…๋ฐ์ดํŠธ + if (this.selectedNode && this.selectedNode.id === node.id) { + this.selectedNode = { ...this.selectedNode, ...updatedNode }; + } + + // ๊ฐ•์ œ ๋ฆฌ๋ Œ๋”๋ง์„ ์œ„ํ•œ ๋”๋ฏธ ์—…๋ฐ์ดํŠธ + this.treeNodes = [...this.treeNodes]; + + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๊ทธ๋ฆฌ๊ธฐ (์—ฐ๊ฒฐ์„  ์—…๋ฐ์ดํŠธ) + this.$nextTick(() => { + this.calculateNodePositions(); + }); + + console.log(`โœ… ์ •์‚ฌ ๊ฒฝ๋กœ ${newCanonicalState ? '์„ค์ •' : 'ํ•ด์ œ'} ์™„๋ฃŒ`); + console.log('๐Ÿ“Š ์—…๋ฐ์ดํŠธ๋œ ๋…ธ๋“œ:', updatedNode); + console.log('๐Ÿ” is_canonical ๊ฐ’:', updatedNode.is_canonical); + console.log('๐Ÿ” canonical_order ๊ฐ’:', updatedNode.canonical_order); + console.log('๐Ÿ”„ ํ˜„์žฌ treeNodes ๊ฐœ์ˆ˜:', this.treeNodes.length); + + // ์—…๋ฐ์ดํŠธ๋œ ๋…ธ๋“œ ์ฐพ๊ธฐ + const updatedNodeInArray = this.treeNodes.find(n => n.id === node.id); + console.log('๐Ÿ” ๋ฐฐ์—ด ๋‚ด ์—…๋ฐ์ดํŠธ๋œ ๋…ธ๋“œ:', updatedNodeInArray?.is_canonical); + } catch (error) { + console.error('โŒ ์ •์‚ฌ ๊ฒฝ๋กœ ํ† ๊ธ€ ์‹คํŒจ:', error); + alert('์ •์‚ฌ ๊ฒฝ๋กœ ์„ค์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋…ธ๋“œ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + startDragNode(event, node) { + // ํ˜„์žฌ๋Š” ๋“œ๋ž˜๊ทธ ๊ธฐ๋Šฅ ๋น„ํ™œ์„ฑํ™” (ํŒจ๋‹๊ณผ ์ถฉ๋Œ ๋ฐฉ์ง€) + event.stopPropagation(); + console.log('๋“œ๋ž˜๊ทธ ์‹œ์ž‘:', node.title); + // TODO: ๋…ธ๋“œ ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ๊ตฌํ˜„ + }, + + // ๋…ธ๋“œ ์ธ๋ผ์ธ ํŽธ์ง‘ + editNodeInline(node) { + // ๋”๋ธ”ํด๋ฆญ ์‹œ ๋…ธ๋“œ ์„ ํƒํ•˜๊ณ  ์—๋””ํ„ฐ๋กœ ํฌ์ปค์Šค + this.selectNode(node); + + // ์—๋””ํ„ฐ๊ฐ€ ์žˆ๋‹ค๋ฉด ํฌ์ปค์Šค + this.$nextTick(() => { + const editorContainer = document.getElementById('editor-container'); + if (editorContainer && this.monacoEditor) { + this.monacoEditor.focus(); + } + }); + + console.log('์ธ๋ผ์ธ ํŽธ์ง‘:', node.title); + }, + + // ๋…ธ๋“œ ์œ„์น˜ ๊ณ„์‚ฐ ๋ฐ ๋ฐ˜ํ™˜ + getNodePosition(node) { + if (!this.nodePositions.has(node.id)) { + this.calculateNodePositions(); + } + + const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 }; + return `left: ${pos.x}px; top: ${pos.y}px;`; + }, + + // ํŠธ๋ฆฌ ๋…ธ๋“œ ์œ„์น˜ ์ž๋™ ๊ณ„์‚ฐ + calculateNodePositions() { + const canvas = document.getElementById('tree-canvas'); + if (!canvas) return; + + const canvasWidth = canvas.clientWidth; + const canvasHeight = canvas.clientHeight; + + // ๋…ธ๋“œ ํฌ๊ธฐ ์„ค์ • + const nodeWidth = 200; + const nodeHeight = 80; + const levelHeight = 150; // ๋ ˆ๋ฒจ ๊ฐ„ ๊ฐ„๊ฒฉ + const nodeSpacing = 50; // ๋…ธ๋“œ ๊ฐ„ ๊ฐ„๊ฒฉ + + // ๋ ˆ๋ฒจ๋ณ„ ๋…ธ๋“œ ๊ทธ๋ฃนํ™” + const levels = new Map(); + + // ๋ฃจํŠธ ๋…ธ๋“œ๋“ค ์ฐพ๊ธฐ + const rootNodes = this.treeNodes.filter(node => !node.parent_id); + + // BFS๋กœ ๋ ˆ๋ฒจ๋ณ„ ๋…ธ๋“œ ๋ฐฐ์น˜ + const queue = []; + rootNodes.forEach(node => { + queue.push({ node, level: 0 }); + }); + + while (queue.length > 0) { + const { node, level } = queue.shift(); + + if (!levels.has(level)) { + levels.set(level, []); + } + levels.get(level).push(node); + + // ์ž์‹ ๋…ธ๋“œ๋“ค์„ ๋‹ค์Œ ๋ ˆ๋ฒจ์— ์ถ”๊ฐ€ + const children = this.getChildNodes(node.id); + children.forEach(child => { + queue.push({ node: child, level: level + 1 }); + }); + } + + // ๊ฐ ๋ ˆ๋ฒจ์˜ ๋…ธ๋“œ๋“ค ์œ„์น˜ ๊ณ„์‚ฐ + levels.forEach((nodes, level) => { + const y = 100 + level * levelHeight; // ์ƒ๋‹จ ์—ฌ๋ฐฑ + ๋ ˆ๋ฒจ * ๋†’์ด + const totalWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing; + const startX = (canvasWidth - totalWidth) / 2; + + nodes.forEach((node, index) => { + const x = startX + index * (nodeWidth + nodeSpacing); + this.nodePositions.set(node.id, { x, y }); + }); + }); + + // ์—ฐ๊ฒฐ์„  ๋‹ค์‹œ ๊ทธ๋ฆฌ๊ธฐ + this.drawConnections(); + }, + + // SVG ์—ฐ๊ฒฐ์„  ๊ทธ๋ฆฌ๊ธฐ + drawConnections() { + const svg = document.getElementById('tree-connections'); + if (!svg) return; + + // ๊ธฐ์กด ์—ฐ๊ฒฐ์„  ์ œ๊ฑฐ + svg.innerHTML = ''; + + // ๊ฐ ๋…ธ๋“œ์˜ ์ž์‹๋“ค๊ณผ ์—ฐ๊ฒฐ์„  ๊ทธ๋ฆฌ๊ธฐ + this.treeNodes.forEach(node => { + const children = this.getChildNodes(node.id); + if (children.length === 0) return; + + const parentPos = this.nodePositions.get(node.id); + if (!parentPos) return; + + children.forEach(child => { + const childPos = this.nodePositions.get(child.id); + if (!childPos) return; + + // ์—ฐ๊ฒฐ์„  ์ƒ์„ฑ + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + // ๋ถ€๋ชจ ๋…ธ๋“œ ํ•˜๋‹จ ์ค‘์•™์—์„œ ์‹œ์ž‘ + const startX = parentPos.x + 100; // ๋…ธ๋“œ ์ค‘์•™ + const startY = parentPos.y + 80; // ๋…ธ๋“œ ํ•˜๋‹จ + + // ์ž์‹ ๋…ธ๋“œ ์ƒ๋‹จ ์ค‘์•™์œผ๋กœ ์—ฐ๊ฒฐ + const endX = childPos.x + 100; // ๋…ธ๋“œ ์ค‘์•™ + const endY = childPos.y; // ๋…ธ๋“œ ์ƒ๋‹จ + + // ๊ณก์„  ๊ฒฝ๋กœ ์ƒ์„ฑ (๋ฒ ์ง€์–ด ๊ณก์„ ) + const midY = startY + (endY - startY) / 2; + const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`; + + line.setAttribute('d', path); + line.setAttribute('stroke', '#9CA3AF'); + line.setAttribute('stroke-width', '2'); + line.setAttribute('fill', 'none'); + line.setAttribute('marker-end', 'url(#arrowhead)'); + + svg.appendChild(line); + }); + }); + + // ํ™”์‚ดํ‘œ ๋งˆ์ปค ์ถ”๊ฐ€ (ํ•œ ๋ฒˆ๋งŒ) + if (!svg.querySelector('#arrowhead')) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '7'); + marker.setAttribute('refX', '9'); + marker.setAttribute('refY', '3.5'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); + polygon.setAttribute('fill', '#9CA3AF'); + + marker.appendChild(polygon); + defs.appendChild(marker); + svg.appendChild(defs); + } + }, + + // ํŠธ๋ฆฌ ์ค‘์•™ ์ •๋ ฌ + centerTree() { + this.treePanX = 0; + this.treePanY = 0; + this.treeZoom = 1; + }, + + // ํ™•๋Œ€ + zoomIn() { + this.treeZoom = Math.min(this.treeZoom * 1.2, 3); + }, + + // ์ถ•์†Œ + zoomOut() { + this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3); + }, + + // ํœ  ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ (ํ™•๋Œ€/์ถ•์†Œ) + handleWheel(event) { + event.preventDefault(); + if (event.deltaY < 0) { + this.zoomIn(); + } else { + this.zoomOut(); + } + }, + + // ํŒจ๋‹ ์‹œ์ž‘ + startPan(event) { + if (event.target.closest('.tree-diagram-node')) return; // ๋…ธ๋“œ ํด๋ฆญ ์‹œ ํŒจ๋‹ ๋ฐฉ์ง€ + + this.isPanning = true; + this.panStartX = event.clientX - this.treePanX; + this.panStartY = event.clientY - this.treePanY; + + document.addEventListener('mousemove', this.handlePan.bind(this)); + document.addEventListener('mouseup', this.stopPan.bind(this)); + }, + + // ํŒจ๋‹ ์ฒ˜๋ฆฌ + handlePan(event) { + if (!this.isPanning) return; + + this.treePanX = event.clientX - this.panStartX; + this.treePanY = event.clientY - this.panStartY; + }, + + // ํŒจ๋‹ ์ข…๋ฃŒ + stopPan() { + this.isPanning = false; + document.removeEventListener('mousemove', this.handlePan); + document.removeEventListener('mouseup', this.stopPan); + }, + + // ์žฌ๊ท€์  ํŠธ๋ฆฌ ๋…ธ๋“œ ๋ Œ๋”๋ง + renderTreeNodeRecursive(node, depth, isLast = false, parentPath = []) { + const hasChildren = this.getChildNodes(node.id).length > 0; + const isExpanded = this.expandedNodes.has(node.id); + const isSelected = this.selectedNode && this.selectedNode.id === node.id; + const isRoot = depth === 0; + + // ์ƒํƒœ๋ณ„ ์ƒ‰์ƒ + let statusClass = 'text-gray-700'; + switch(node.status) { + case 'draft': statusClass = 'text-gray-500'; break; + case 'writing': statusClass = 'text-yellow-600'; break; + case 'review': statusClass = 'text-blue-600'; break; + case 'complete': statusClass = 'text-green-600'; break; + } + + // ํŠธ๋ฆฌ ๋ผ์ธ ์ƒ์„ฑ + let treeLines = ''; + for (let i = 0; i < depth; i++) { + if (i < parentPath.length && parentPath[i]) { + // ๋ถ€๋ชจ๊ฐ€ ๋งˆ์ง€๋ง‰์ด ์•„๋‹ˆ๋ฉด ์„ธ๋กœ์„  ํ‘œ์‹œ + treeLines += ''; + } else { + // ๋นˆ ๊ณต๊ฐ„ + treeLines += ''; + } + } + + // ํ˜„์žฌ ๋…ธ๋“œ์˜ ์—ฐ๊ฒฐ์„  + let nodeConnector = ''; + if (!isRoot) { + if (isLast) { + nodeConnector = ''; // โ””โ”€ + } else { + nodeConnector = ''; // โ”œโ”€ + } + } + + let html = ` +
+
+ +
+ ${treeLines} + ${nodeConnector} + + + ${hasChildren ? + `` : + '' + } + + + ${this.getNodeIcon(node.node_type)} + + + ${node.title} +
+ + +
+ + +
+ + + ${node.word_count > 0 ? + `${node.word_count}w` : + '' + } +
+ `; + + // ์ž์‹ ๋…ธ๋“œ๋“ค ์žฌ๊ท€์ ์œผ๋กœ ๋ Œ๋”๋ง + if (hasChildren && isExpanded) { + const children = this.getChildNodes(node.id); + const newParentPath = [...parentPath, !isLast]; + + children.forEach((child, index) => { + const isChildLast = index === children.length - 1; + html += this.renderTreeNodeRecursive(child, depth + 1, isChildLast, newParentPath); + }); + } + + html += '
'; + return html; + }, + + // ๋…ธ๋“œ ํ† ๊ธ€ + toggleNode(nodeId) { + if (this.expandedNodes.has(nodeId)) { + this.expandedNodes.delete(nodeId); + } else { + this.expandedNodes.add(nodeId); + } + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๋ Œ๋”๋ง์„ ์œ„ํ•ด ์ƒํƒœ ์—…๋ฐ์ดํŠธ + this.$nextTick(() => { + this.rerenderTree(); + }); + }, + + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๋ Œ๋”๋ง + rerenderTree() { + // Alpine.js์˜ ๋ฐ˜์‘์„ฑ์„ ํŠธ๋ฆฌ๊ฑฐํ•˜๊ธฐ ์œ„ํ•ด ๋ฐฐ์—ด์„ ์ƒˆ๋กœ ํ• ๋‹น + this.treeNodes = [...this.treeNodes]; + // ๊ฐ•์ œ๋กœ DOM ์—…๋ฐ์ดํŠธ + this.$nextTick(() => { + // ํŠธ๋ฆฌ ์ปจํ…Œ์ด๋„ˆ ์ฐพ์•„์„œ ๋‹ค์‹œ ๋ Œ๋”๋ง + const treeContainer = document.querySelector('.tree-container'); + if (treeContainer) { + // Alpine.js๊ฐ€ ์ž๋™์œผ๋กœ ๋‹ค์‹œ ๋ Œ๋”๋งํ•จ + } + }); + }, + + // ๋ชจ๋‘ ํŽผ์น˜๊ธฐ + expandAll() { + this.treeNodes.forEach(node => { + if (this.getChildNodes(node.id).length > 0) { + this.expandedNodes.add(node.id); + } + }); + this.rerenderTree(); + }, + + // ๋ชจ๋‘ ์ ‘๊ธฐ + collapseAll() { + this.expandedNodes.clear(); + this.rerenderTree(); + }, + + // Monaco Editor ์ดˆ๊ธฐํ™” + async initMonacoEditor() { + console.log('๐ŸŽจ Monaco Editor ์ดˆ๊ธฐํ™” ์‹œ์ž‘...'); + + // Monaco Editor ๋กœ๋”๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ + let retries = 0; + while (typeof require === 'undefined' && retries < 30) { + console.log(`โณ Monaco Editor ๋กœ๋” ๋Œ€๊ธฐ ์ค‘... (${retries + 1}/30)`); + await new Promise(resolve => setTimeout(resolve, 200)); + retries++; + } + + if (typeof require === 'undefined') { + console.warn('โŒ Monaco Editor ๋กœ๋”๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ธฐ๋ณธ textarea๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.'); + this.setupFallbackEditor(); + return; + } + + try { + require.config({ + paths: { + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' + } + }); + + require(['vs/editor/editor.main'], () => { + // ์—๋””ํ„ฐ ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ + const waitForEditor = () => { + const editorElement = document.getElementById('monaco-editor'); + if (!editorElement) { + console.log('โณ Monaco Editor ์ปจํ…Œ์ด๋„ˆ ๋Œ€๊ธฐ ์ค‘...'); + setTimeout(waitForEditor, 100); + return; + } + + // ์ด๋ฏธ Monaco Editor๊ฐ€ ์ƒ์„ฑ๋˜์–ด ์žˆ๋‹ค๋ฉด ์ œ๊ฑฐ + if (monacoEditor) { + monacoEditor.dispose(); + monacoEditor = null; + } + + monacoEditor = monaco.editor.create(editorElement, { + value: '', + language: 'markdown', + theme: 'vs-light', + automaticLayout: true, + wordWrap: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 14, + lineNumbers: 'on', + folding: true, + renderWhitespace: 'boundary' + }); + + // ๋‚ด์šฉ ๋ณ€๊ฒฝ ๊ฐ์ง€ + monacoEditor.onDidChangeModelContent(() => { + this.isEditorDirty = true; + }); + + console.log('โœ… Monaco Editor ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + }; + + // ์—๋””ํ„ฐ ์ปจํ…Œ์ด๋„ˆ ๋Œ€๊ธฐ ์‹œ์ž‘ + waitForEditor(); + }); + } catch (error) { + console.error('โŒ Monaco Editor ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + this.setupFallbackEditor(); + } + }, + + // ํด๋ฐฑ ์—๋””ํ„ฐ ์„ค์ • (Monaco๊ฐ€ ์‹คํŒจํ–ˆ์„ ๋•Œ) + setupFallbackEditor() { + console.log('๐Ÿ“ ํด๋ฐฑ textarea ์—๋””ํ„ฐ ์„ค์ • ์ค‘...'); + const editorElement = document.getElementById('monaco-editor'); + if (editorElement) { + editorElement.innerHTML = ` + + `; + + const textarea = document.getElementById('fallback-editor'); + if (textarea) { + textarea.addEventListener('input', () => { + this.isEditorDirty = true; + }); + console.log('โœ… ํด๋ฐฑ ์—๋””ํ„ฐ ์„ค์ • ์™„๋ฃŒ'); + } + } + }, + + // ๋กœ๊ทธ์ธ ๋ชจ๋‹ฌ ์—ด๊ธฐ + openLoginModal() { + this.showLoginModal = true; + this.loginForm = { email: '', password: '' }; + this.loginError = ''; + }, + + // ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ + async handleLogin() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (response.success) { + this.currentUser = response.user; + this.showLoginModal = false; + + // ํŠธ๋ฆฌ ๋ชฉ๋ก ๋‹ค์‹œ ๋กœ๋“œ + await this.loadUserTrees(); + } else { + this.loginError = response.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + this.loginError = '๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } finally { + this.loginLoading = false; + } + }, + + // ๋กœ๊ทธ์•„์›ƒ + async logout() { + try { + await window.api.logout(); + this.currentUser = null; + this.userTrees = []; + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + console.log('โœ… ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋กœ๊ทธ์•„์›ƒ ์‹คํŒจ:', error); + } + } + }; +}; + +// ์ „์—ญ ์ธ์Šคํ„ด์Šค ๋“ฑ๋ก (HTML์—์„œ ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•ด) +window.memoTreeInstance = null; + +// Alpine.js ์ดˆ๊ธฐํ™” ํ›„ ์ „์—ญ ์ธ์Šคํ„ด์Šค ์„ค์ • +document.addEventListener('alpine:init', () => { + // ํŽ˜์ด์ง€ ๋กœ๋“œ ํ›„ ์ธ์Šคํ„ด์Šค ๋“ฑ๋ก + setTimeout(() => { + const appElement = document.querySelector('[x-data="memoTreeApp()"]'); + if (appElement && appElement._x_dataStack) { + window.memoTreeInstance = appElement._x_dataStack[0]; + } + }, 100); +}); + +// ํŠธ๋ฆฌ ๋…ธ๋“œ ์ปดํฌ๋„ŒํŠธ +window.treeNodeComponent = function(node) { + return { + node: node, + expanded: true, + + get hasChildren() { + return window.memoTreeInstance?.getChildNodes(this.node.id).length > 0; + }, + + toggleExpanded() { + this.expanded = !this.expanded; + if (this.expanded) { + window.memoTreeInstance?.expandedNodes.add(this.node.id); + } else { + window.memoTreeInstance?.expandedNodes.delete(this.node.id); + } + } + }; +}; + +console.log('๐ŸŒณ ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ JavaScript ๋กœ๋“œ ์™„๋ฃŒ'); diff --git a/frontend/static/js/story-view.js b/frontend/static/js/story-view.js new file mode 100644 index 0000000..dbb2a30 --- /dev/null +++ b/frontend/static/js/story-view.js @@ -0,0 +1,345 @@ +// story-view.js - ์ •์‚ฌ ๊ฒฝ๋กœ ์Šคํ† ๋ฆฌ ๋ทฐ ์ปดํฌ๋„ŒํŠธ + +console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋ทฐ JavaScript ๋กœ๋“œ ์™„๋ฃŒ'); + +// Alpine.js ์ปดํฌ๋„ŒํŠธ +window.storyViewApp = function() { + return { + // ์‚ฌ์šฉ์ž ์ƒํƒœ + currentUser: null, + + // ํŠธ๋ฆฌ ๋ฐ์ดํ„ฐ + userTrees: [], + selectedTreeId: '', + selectedTree: null, + canonicalNodes: [], // ์ •์‚ฌ ๊ฒฝ๋กœ ๋…ธ๋“œ๋“ค๋งŒ + + // UI ์ƒํƒœ + viewMode: 'toc', // 'toc' | 'full' + showLoginModal: false, + showEditModal: false, + editingNode: null, + + // ๋กœ๊ทธ์ธ ํผ ์ƒํƒœ + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // ๊ณ„์‚ฐ๋œ ์†์„ฑ๋“ค + get totalWords() { + return this.canonicalNodes.reduce((sum, node) => sum + (node.word_count || 0), 0); + }, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋ทฐ ์ดˆ๊ธฐํ™” ์ค‘...'); + + // API ๋กœ๋“œ ๋Œ€๊ธฐ + let retryCount = 0; + while (!window.api && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.api) { + console.error('โŒ API๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์‚ฌ์šฉ์ž ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + // ์ธ์ฆ๋œ ๊ฒฝ์šฐ ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + if (this.currentUser) { + await this.loadUserTrees(); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.currentUser = user; + console.log('โœ… ์‚ฌ์šฉ์ž ์ธ์ฆ๋จ:', user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ:', error.message); + this.currentUser = null; + + // ๋งŒ๋ฃŒ๋œ ํ† ํฐ ์ •๋ฆฌ + localStorage.removeItem('token'); + } + }, + + // ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + async loadUserTrees() { + try { + console.log('๐Ÿ“Š ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋”ฉ...'); + const trees = await window.api.getUserMemoTrees(); + this.userTrees = trees || []; + console.log(`โœ… ${this.userTrees.length}๊ฐœ ํŠธ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ`); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + this.userTrees = []; + } + }, + + // ์Šคํ† ๋ฆฌ ๋กœ๋“œ (์ •์‚ฌ ๊ฒฝ๋กœ๋งŒ) + async loadStory(treeId) { + if (!treeId) { + this.selectedTree = null; + this.canonicalNodes = []; + return; + } + + try { + console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋กœ๋”ฉ:', treeId); + + // ํŠธ๋ฆฌ ์ •๋ณด ๋กœ๋“œ + this.selectedTree = await window.api.getMemoTree(treeId); + + // ๋ชจ๋“  ๋…ธ๋“œ ๋กœ๋“œ + const allNodes = await window.api.getMemoTreeNodes(treeId); + + // ์ •์‚ฌ ๊ฒฝ๋กœ ๋…ธ๋“œ๋“ค๋งŒ ํ•„ํ„ฐ๋งํ•˜๊ณ  ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ + this.canonicalNodes = allNodes + .filter(node => node.is_canonical) + .sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0)); + + console.log(`โœ… ์Šคํ† ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ: ${this.canonicalNodes.length}๊ฐœ ์ •์‚ฌ ๋…ธ๋“œ`); + } catch (error) { + console.error('โŒ ์Šคํ† ๋ฆฌ ๋กœ๋“œ ์‹คํŒจ:', error); + alert('์Šคํ† ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋ทฐ ๋ชจ๋“œ ํ† ๊ธ€ + toggleView() { + this.viewMode = this.viewMode === 'toc' ? 'full' : 'toc'; + }, + + // ์ฑ•ํ„ฐ๋กœ ์Šคํฌ๋กค + scrollToChapter(nodeId) { + if (this.viewMode === 'toc') { + this.viewMode = 'full'; + // DOM ์—…๋ฐ์ดํŠธ ๋Œ€๊ธฐ ํ›„ ์Šคํฌ๋กค + this.$nextTick(() => { + const element = document.getElementById(`chapter-${nodeId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + } else { + const element = document.getElementById(`chapter-${nodeId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }, + + // ์ฑ•ํ„ฐ ํŽธ์ง‘ (์ธ๋ผ์ธ ๋ชจ๋‹ฌ) + editChapter(node) { + this.editingNode = { ...node }; // ๋ณต์‚ฌ๋ณธ ์ƒ์„ฑ + this.showEditModal = true; + }, + + // ํŽธ์ง‘ ์ทจ์†Œ + cancelEdit() { + this.showEditModal = false; + this.editingNode = null; + }, + + // ํŽธ์ง‘ ์ €์žฅ + async saveEdit() { + if (!this.editingNode) return; + + try { + console.log('๐Ÿ’พ ์ฑ•ํ„ฐ ์ €์žฅ ์ค‘:', this.editingNode.title); + + const updatedNode = await window.api.updateMemoNode(this.editingNode.id, { + title: this.editingNode.title, + content: this.editingNode.content + }); + + // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + const nodeIndex = this.canonicalNodes.findIndex(n => n.id === this.editingNode.id); + if (nodeIndex !== -1) { + this.canonicalNodes = this.canonicalNodes.map(n => + n.id === this.editingNode.id ? { ...n, ...updatedNode } : n + ); + } + + this.showEditModal = false; + this.editingNode = null; + + console.log('โœ… ์ฑ•ํ„ฐ ์ €์žฅ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ์ฑ•ํ„ฐ ์ €์žฅ ์‹คํŒจ:', error); + alert('์ฑ•ํ„ฐ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ + async exportStory() { + if (!this.selectedTree || this.canonicalNodes.length === 0) { + alert('๋‚ด๋ณด๋‚ผ ์Šคํ† ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + try { + // ํ…์ŠคํŠธ ํ˜•ํƒœ๋กœ ์Šคํ† ๋ฆฌ ์ƒ์„ฑ + let storyText = `${this.selectedTree.title}\n`; + storyText += `${'='.repeat(this.selectedTree.title.length)}\n\n`; + + if (this.selectedTree.description) { + storyText += `${this.selectedTree.description}\n\n`; + } + + storyText += `์ž‘์„ฑ์ผ: ${this.formatDate(this.selectedTree.created_at)}\n`; + storyText += `์ˆ˜์ •์ผ: ${this.formatDate(this.selectedTree.updated_at)}\n`; + storyText += `์ด ${this.canonicalNodes.length}๊ฐœ ์ฑ•ํ„ฐ, ${this.totalWords}๋‹จ์–ด\n\n`; + storyText += `${'='.repeat(50)}\n\n`; + + this.canonicalNodes.forEach((node, index) => { + storyText += `${index + 1}. ${node.title}\n`; + storyText += `${'-'.repeat(node.title.length + 3)}\n\n`; + + if (node.content) { + // HTML ํƒœ๊ทธ ์ œ๊ฑฐ + const plainText = node.content.replace(/<[^>]*>/g, ''); + storyText += `${plainText}\n\n`; + } else { + storyText += `[์ด ์ฑ•ํ„ฐ๋Š” ์•„์ง ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค]\n\n`; + } + + storyText += `${'โ”€'.repeat(30)}\n\n`; + }); + + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + const blob = new Blob([storyText], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.selectedTree.title}_์ •์‚ฌ์Šคํ† ๋ฆฌ.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('โœ… ์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:', error); + alert('์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์Šคํ† ๋ฆฌ ์ธ์‡„ + printStory() { + if (!this.selectedTree || this.canonicalNodes.length === 0) { + alert('์ธ์‡„ํ•  ์Šคํ† ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์ „์ฒด ๋ทฐ๋กœ ๋ณ€๊ฒฝ ํ›„ ์ธ์‡„ + if (this.viewMode === 'toc') { + this.viewMode = 'full'; + this.$nextTick(() => { + window.print(); + }); + } else { + window.print(); + } + }, + + // ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + formatContent(content) { + if (!content) return ''; + + // ๊ฐ„๋‹จํ•œ ๋งˆํฌ๋‹ค์šด ์Šคํƒ€์ผ ๋ณ€ํ™˜ + return content + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + .replace(/^/, '

') + .replace(/$/, '

'); + }, + + getNodeTypeLabel(nodeType) { + const labels = { + 'folder': '๐Ÿ“ ํด๋”', + 'memo': '๐Ÿ“ ๋ฉ”๋ชจ', + 'chapter': '๐Ÿ“– ์ฑ•ํ„ฐ', + 'character': '๐Ÿ‘ค ์ธ๋ฌผ', + 'plot': '๐Ÿ“‹ ํ”Œ๋กฏ' + }; + return labels[nodeType] || '๐Ÿ“ ๋ฉ”๋ชจ'; + }, + + getStatusLabel(status) { + const labels = { + 'draft': '๐Ÿ“ ์ดˆ์•ˆ', + 'writing': 'โœ๏ธ ์ž‘์„ฑ์ค‘', + 'review': '๐Ÿ‘€ ๊ฒ€ํ† ์ค‘', + 'complete': 'โœ… ์™„๋ฃŒ' + }; + return labels[status] || '๐Ÿ“ ์ดˆ์•ˆ'; + }, + + // ๋กœ๊ทธ์ธ ๊ด€๋ จ ํ•จ์ˆ˜๋“ค + openLoginModal() { + this.showLoginModal = true; + this.loginForm = { email: '', password: '' }; + this.loginError = ''; + }, + + async handleLogin() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (response.success) { + this.currentUser = response.user; + this.showLoginModal = false; + + // ํŠธ๋ฆฌ ๋ชฉ๋ก ๋‹ค์‹œ ๋กœ๋“œ + await this.loadUserTrees(); + } else { + this.loginError = response.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + this.loginError = '๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } finally { + this.loginLoading = false; + } + }, + + async logout() { + try { + await window.api.logout(); + this.currentUser = null; + this.userTrees = []; + this.selectedTree = null; + this.canonicalNodes = []; + console.log('โœ… ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋กœ๊ทธ์•„์›ƒ ์‹คํŒจ:', error); + } + } + }; +}; + +console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋ทฐ ์ปดํฌ๋„ŒํŠธ ๋“ฑ๋ก ์™„๋ฃŒ'); diff --git a/frontend/story-view.html b/frontend/story-view.html new file mode 100644 index 0000000..723b07d --- /dev/null +++ b/frontend/story-view.html @@ -0,0 +1,389 @@ + + + + + + ์Šคํ† ๋ฆฌ ๋ทฐ - ์ •์‚ฌ ๊ฒฝ๋กœ + + + + + + +
+
+
+ + + + +
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + โ€ข + +
+
+ + +
+ + + +
+
+
+ + +
+ +

์Šคํ† ๋ฆฌ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+

์œ„์—์„œ ํŠธ๋ฆฌ๋ฅผ ์„ ํƒํ•˜๋ฉด ์ •์‚ฌ ๊ฒฝ๋กœ๊ฐ€ ๋ชฉ์ฐจ ํ˜•ํƒœ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค

+
+ + +
+ +

์ •์‚ฌ ๊ฒฝ๋กœ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+

ํŠธ๋ฆฌ ์—๋””ํ„ฐ์—์„œ ๋…ธ๋“œ๋“ค์„ ์ •์‚ฌ๋กœ ์„ค์ •ํ•ด์ฃผ์„ธ์š”

+ + ํŠธ๋ฆฌ ์—๋””ํ„ฐ๋กœ ๊ฐ€๊ธฐ + +
+ + +
+ + +
+

+

+
+ + + + +
+
+ + +
+
+

+ ๋ชฉ์ฐจ (์ •์‚ฌ ๊ฒฝ๋กœ) +

+
+
+
+ +
+
+
+ + +
+
+

+ ์ „์ฒด ์Šคํ† ๋ฆฌ +

+
+
+ +
+
+
+
+
+ + +
+
+ +

๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค

+

์Šคํ† ๋ฆฌ๋ฅผ ๋ณด๋ ค๋ฉด ๋จผ์ € ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”

+ +
+
+ + +
+
+
+
+

์ฑ•ํ„ฐ ํŽธ์ง‘

+ +
+
+ +
+
+ +
+ + +
+ + +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+

๋กœ๊ทธ์ธ

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + + + +