SOY NV AI commited on
Commit
eb0b3d8
ยท
1 Parent(s): c1b3f58

Update: UI adjustments in personal management and various improvements

Browse files
agent_skills/notion_db_skill.ts ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Notion DB ์ฝ๊ธฐ/์“ฐ๊ธฐ์šฉ Agent Skill (Tool) ์Šค์ผˆ๋ ˆํ†ค
3
+ *
4
+ * - ์ด ํ”„๋กœ์ ํŠธ๋Š” Python(Flask) ๊ธฐ๋ฐ˜์ด์ง€๋งŒ, Gemini Tool/Skill ์ •์˜๋Š” TS๋กœ๋„ ์ž‘์„ฑํ•ด ๋‘˜ ์ˆ˜ ์žˆ์–ด
5
+ * ์ถ”ํ›„ Node ๋Ÿฐํƒ€์ž„/ํˆด์ฒด์ธ์œผ๋กœ ์ด์‹ํ•˜๊ฑฐ๋‚˜ ๋ณ„๋„ ์›Œ์ปค์—์„œ ์‹คํ–‰ํ•˜๊ธฐ ์ข‹์Šต๋‹ˆ๋‹ค.
6
+ *
7
+ * ํ•„์š” ํŒจํ‚ค์ง€(์˜ˆ์‹œ):
8
+ * - @notionhq/client
9
+ */
10
+
11
+ // ----------------------------
12
+ // 1) Gemini๊ฐ€ ์ดํ•ดํ•  "์—ญํ• " ํ•œ ๋ฌธ์žฅ
13
+ // ----------------------------
14
+ export const NOTION_DB_SKILL_ROLE_SENTENCE =
15
+ "Notion์˜ ํŠน์ • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํŽ˜์ด์ง€๋ฅผ ์กฐ๊ฑด์œผ๋กœ ์กฐํšŒํ•˜๊ณ , ์ƒˆ ํŽ˜์ด์ง€ ์ƒ์„ฑ/๊ธฐ์กด ํŽ˜์ด์ง€ ์—…๋ฐ์ดํŠธ/์•„์นด์ด๋ธŒ๋ฅผ ์ˆ˜ํ–‰ํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ๋™๊ธฐํ™”ํ•˜๋Š” ์Šคํ‚ฌ์ด๋‹ค.";
16
+
17
+ // ----------------------------
18
+ // 2) Agent Tool input_schema (JSON Schema)
19
+ // ----------------------------
20
+ export const NOTION_DB_SKILL_INPUT_SCHEMA = {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ properties: {
24
+ operation: {
25
+ type: "string",
26
+ description: "์ˆ˜ํ–‰ํ•  ์ž‘์—…",
27
+ enum: ["query", "create", "update", "upsert", "archive"],
28
+ },
29
+ // ํŠน์ • DB ์ด๋ฆ„(ํ‘œ์‹œ/๋กœ๊ทธ์šฉ). ์‹ค์ œ ์‹คํ–‰์€ database_id๊ฐ€ ์šฐ์„ ์ž…๋‹ˆ๋‹ค.
30
+ database_name: {
31
+ type: "string",
32
+ description: "๋Œ€์ƒ Notion DB ์ด๋ฆ„(ํ‘œ์‹œ์šฉ)",
33
+ },
34
+ // ๊ณ ์ • DB ์ตœ์ ํ™”๋ฅผ ์œ„ํ•ด ๋ณดํ†ต์€ ์„œ๋ฒ„/์ปจํ…์ŠคํŠธ์— ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋‘๊ณ , ํ•„์š” ์‹œ override ํ•ฉ๋‹ˆ๋‹ค.
35
+ database_id: {
36
+ type: "string",
37
+ description: "๋Œ€์ƒ Notion Database ID",
38
+ },
39
+ // query ์ „์šฉ
40
+ query: {
41
+ type: "object",
42
+ additionalProperties: false,
43
+ description: "Notion DB query ์˜ต์…˜",
44
+ properties: {
45
+ filter: {
46
+ type: "object",
47
+ description: "Notion API filter ๊ฐ์ฒด(๊ทธ๋Œ€๋กœ ์ „๋‹ฌ)",
48
+ },
49
+ sorts: {
50
+ type: "array",
51
+ description: "Notion API sorts ๋ฐฐ์—ด(๊ทธ๋Œ€๋กœ ์ „๋‹ฌ)",
52
+ items: { type: "object" },
53
+ },
54
+ page_size: {
55
+ type: "integer",
56
+ minimum: 1,
57
+ maximum: 100,
58
+ description: "ํ•œ ๋ฒˆ์— ๊ฐ€์ ธ์˜ฌ ๊ฒฐ๊ณผ ์ˆ˜(1~100)",
59
+ },
60
+ },
61
+ },
62
+ // create/update/upsert ์ „์šฉ
63
+ properties: {
64
+ type: "object",
65
+ description:
66
+ "Notion Page properties ๊ฐ์ฒด(๊ทธ๋Œ€๋กœ ์ „๋‹ฌ). ์˜ˆ: { Name: { title: [{ text: { content: '...' } }] } }",
67
+ },
68
+ // update/archive ์ „์šฉ
69
+ page_id: {
70
+ type: "string",
71
+ description: "๋Œ€์ƒ Notion Page ID (update/archive)",
72
+ },
73
+ // upsert ์ „์šฉ(์Šค์ผˆ๋ ˆํ†ค์—์„œ๋Š” ๊ตฌ์กฐ๋งŒ ์ •์˜)
74
+ upsert: {
75
+ type: "object",
76
+ additionalProperties: false,
77
+ description:
78
+ "์—…์„œํŠธ ํ‚ค(์œ ๋‹ˆํฌ ์†์„ฑ) ๊ธฐ๋ฐ˜์œผ๋กœ ์žˆ์œผ๋ฉด update, ์—†์œผ๋ฉด create",
79
+ properties: {
80
+ unique_property_name: {
81
+ type: "string",
82
+ description: "์œ ๋‹ˆํฌ ํ‚ค๋กœ ์‚ฌ์šฉํ•  Notion ์†์„ฑ ์ด๋ฆ„(์˜ˆ: ExternalId)",
83
+ },
84
+ unique_property_value: {
85
+ type: "string",
86
+ description: "์œ ๋‹ˆํฌ ํ‚ค ๊ฐ’",
87
+ },
88
+ },
89
+ required: ["unique_property_name", "unique_property_value"],
90
+ },
91
+ },
92
+ required: ["operation"],
93
+ } as const;
94
+
95
+ // ----------------------------
96
+ // 3) TypeScript ์ธํ„ฐํŽ˜์ด์Šค + execute ์Šค์ผˆ๋ ˆํ†ค
97
+ // ----------------------------
98
+
99
+ export type NotionDbSkillOperation =
100
+ | "query"
101
+ | "create"
102
+ | "update"
103
+ | "upsert"
104
+ | "archive";
105
+
106
+ export type NotionDbSkillInput = {
107
+ operation: NotionDbSkillOperation;
108
+ database_name?: string;
109
+ database_id?: string;
110
+ query?: {
111
+ filter?: Record<string, unknown>;
112
+ sorts?: Array<Record<string, unknown>>;
113
+ page_size?: number;
114
+ };
115
+ properties?: Record<string, unknown>;
116
+ page_id?: string;
117
+ upsert?: {
118
+ unique_property_name: string;
119
+ unique_property_value: string;
120
+ };
121
+ };
122
+
123
+ export type NotionDbSkillOutput = {
124
+ operation: NotionDbSkillOperation;
125
+ database_id?: string;
126
+ database_name?: string;
127
+ result: unknown;
128
+ };
129
+
130
+ export interface AgentSkill<TInput, TOutput> {
131
+ name: string;
132
+ description: string;
133
+ input_schema: unknown;
134
+ execute(input: TInput, ctx: unknown): Promise<TOutput>;
135
+ }
136
+
137
+ export type NotionSkillContext = {
138
+ notionToken: string;
139
+ defaultDatabaseId?: string;
140
+ defaultDatabaseName?: string;
141
+ };
142
+
143
+ export const notionDbReadWriteSkill: AgentSkill<
144
+ NotionDbSkillInput,
145
+ NotionDbSkillOutput
146
+ > = {
147
+ name: "notion.db.read_write",
148
+ description: NOTION_DB_SKILL_ROLE_SENTENCE,
149
+ input_schema: NOTION_DB_SKILL_INPUT_SCHEMA,
150
+
151
+ async execute(input, ctx) {
152
+ // lazy import: ์‹ค์ œ ๋Ÿฐํƒ€์ž„์—์„œ๋งŒ @notionhq/client ํ•„์š”
153
+ const { Client } = await import("@notionhq/client");
154
+
155
+ const context = ctx as NotionSkillContext;
156
+ if (!context?.notionToken) {
157
+ throw new Error("Notion Integration Token์ด ์—†์Šต๋‹ˆ๋‹ค (ctx.notionToken).");
158
+ }
159
+
160
+ const databaseId = input.database_id || context.defaultDatabaseId;
161
+ const databaseName = input.database_name || context.defaultDatabaseName;
162
+
163
+ const notion = new Client({ auth: context.notionToken });
164
+
165
+ switch (input.operation) {
166
+ case "query": {
167
+ if (!databaseId) throw new Error("database_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
168
+ const res = await notion.databases.query({
169
+ database_id: databaseId,
170
+ filter: input.query?.filter as any,
171
+ sorts: input.query?.sorts as any,
172
+ page_size: input.query?.page_size,
173
+ });
174
+ return { operation: input.operation, database_id: databaseId, database_name: databaseName, result: res };
175
+ }
176
+
177
+ case "create": {
178
+ if (!databaseId) throw new Error("database_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
179
+ if (!input.properties) throw new Error("create์—๋Š” properties๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
180
+ const res = await notion.pages.create({
181
+ parent: { database_id: databaseId },
182
+ properties: input.properties as any,
183
+ });
184
+ return { operation: input.operation, database_id: databaseId, database_name: databaseName, result: res };
185
+ }
186
+
187
+ case "update": {
188
+ if (!input.page_id) throw new Error("update์—๋Š” page_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
189
+ if (!input.properties) throw new Error("update์—๋Š” properties๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
190
+ const res = await notion.pages.update({
191
+ page_id: input.page_id,
192
+ properties: input.properties as any,
193
+ });
194
+ return { operation: input.operation, database_id: databaseId, database_name: databaseName, result: res };
195
+ }
196
+
197
+ case "archive": {
198
+ if (!input.page_id) throw new Error("archive์—๋Š” page_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
199
+ const res = await notion.pages.update({
200
+ page_id: input.page_id,
201
+ archived: true,
202
+ } as any);
203
+ return { operation: input.operation, database_id: databaseId, database_name: databaseName, result: res };
204
+ }
205
+
206
+ case "upsert": {
207
+ if (!databaseId) throw new Error("database_id๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
208
+ if (!input.upsert) throw new Error("upsert์—๋Š” upsert ์ •๋ณด๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
209
+ if (!input.properties) throw new Error("upsert์—๋Š” properties(์—…๋ฐ์ดํŠธ/์ƒ์„ฑํ•  ๊ฐ’)๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
210
+
211
+ // TODO: unique_property_name/value ๊ธฐ๋ฐ˜์œผ๋กœ DB์—์„œ ๋จผ์ € ๊ฒ€์ƒ‰ โ†’ ์žˆ์œผ๋ฉด update, ์—†์œผ๋ฉด create
212
+ // 1) query: filter๋กœ unique property ๋งค์นญ
213
+ // 2) results[0] ์žˆ์œผ๋ฉด pages.update
214
+ // 3) ์—†์œผ๋ฉด pages.create
215
+ throw new Error("upsert๋Š” ์•„์ง ์Šค์ผˆ๋ ˆํ†ค์ž…๋‹ˆ๋‹ค. TODO ๊ตฌํ˜„์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
216
+ }
217
+ }
218
+ },
219
+ };
220
+
221
+
app/core/logger.py CHANGED
@@ -36,7 +36,18 @@ def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
36
  logger.setLevel(level)
37
 
38
  # ์ฝ˜์†” ํ•ธ๋“ค๋Ÿฌ
39
- console_handler = logging.StreamHandler(sys.stdout)
 
 
 
 
 
 
 
 
 
 
 
40
  console_handler.setLevel(level)
41
  console_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
42
  console_handler.setFormatter(console_formatter)
 
36
  logger.setLevel(level)
37
 
38
  # ์ฝ˜์†” ํ•ธ๋“ค๋Ÿฌ
39
+ # Windows์—์„œ cp949 ์ธ์ฝ”๋”ฉ์œผ๋กœ ์ธํ•œ ์ด๋ชจ์ง€(\u26a0 ๋“ฑ) ์ถœ๋ ฅ ์˜ค๋ฅ˜ ํ•ด๊ฒฐ
40
+ stream = sys.stdout
41
+ if sys.platform == 'win32':
42
+ for s in [sys.stdout, sys.stderr]:
43
+ if hasattr(s, 'reconfigure'):
44
+ try:
45
+ # ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋Œ€์ฒด ๋ฌธ์ž๋กœ ์น˜ํ™˜ํ•˜์—ฌ ๋กœ๊ทธ ์ถœ๋ ฅ ์ค‘๋‹จ ๋ฐฉ์ง€
46
+ s.reconfigure(errors='replace')
47
+ except Exception:
48
+ pass
49
+
50
+ console_handler = logging.StreamHandler(stream)
51
  console_handler.setLevel(level)
52
  console_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
53
  console_handler.setFormatter(console_formatter)
app/database.py CHANGED
@@ -797,3 +797,20 @@ class WebtoonStageKeyMapping(db.Model):
797
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
798
  }
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
798
  }
799
 
800
+ # Notion ์ผ์ •ํ‘œ ๋ฐ์ดํ„ฐ ์บ์‹œ ๋ชจ๋ธ
801
+ class NotionScheduleCache(db.Model):
802
+ __tablename__ = 'notion_schedule_cache'
803
+ id = db.Column(db.Integer, primary_key=True)
804
+ notion_id = db.Column(db.String(100), unique=True, nullable=False, index=True)
805
+ name = db.Column(db.String(255), nullable=True, index=True) # ๋‹ด๋‹น์ž ์ด๋ฆ„ (name_filter ๋Œ€์‘)
806
+ start_date = db.Column(db.String(20), nullable=True, index=True) # YYYY-MM-DD
807
+ end_date = db.Column(db.String(20), nullable=True, index=True) # YYYY-MM-DD
808
+ data_json = db.Column(db.Text, nullable=False) # ์ „์ฒด ๋ฐ์ดํ„ฐ (properties ํฌํ•จ)
809
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
810
+
811
+ def to_dict(self):
812
+ try:
813
+ return json.loads(self.data_json)
814
+ except:
815
+ return {}
816
+
app/migrations.py CHANGED
@@ -121,6 +121,16 @@ def check_and_migrate_db(app):
121
  logger.error(f"webtoon_milestone_manager ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘ ์˜ค๋ฅ˜: {e}")
122
  conn.rollback()
123
 
 
 
 
 
 
 
 
 
 
 
124
  logger.info("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ")
125
 
126
  except Exception as e:
 
121
  logger.error(f"webtoon_milestone_manager ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘ ์˜ค๋ฅ˜: {e}")
122
  conn.rollback()
123
 
124
+ # 5. notion_schedule_cache ํ…Œ์ด๋ธ” ํ™•์ธ ๋ฐ ์ƒ์„ฑ
125
+ if 'notion_schedule_cache' not in table_names:
126
+ logger.info("notion_schedule_cache ํ…Œ์ด๋ธ”์ด ์—†์–ด ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.")
127
+ try:
128
+ # db.create_all()์€ ๋ชจ๋“  ๋ˆ„๋ฝ๋œ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๋ฏ€๋กœ ์•ˆ์ „ํ•จ
129
+ db.create_all()
130
+ logger.info("notion_schedule_cache ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ")
131
+ except Exception as e:
132
+ logger.error(f"notion_schedule_cache ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹คํŒจ: {e}")
133
+
134
  logger.info("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ")
135
 
136
  except Exception as e:
app/routes.py CHANGED
@@ -14,7 +14,7 @@ from app.database import (
14
  WebtoonWBSAnalysis, WebtoonWBSJob,
15
  WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
16
  WebtoonStageKeyMapping, WebtoonMilestone, WebtoonMilestoneManager,
17
- Notice, NoticeRecipient
18
  )
19
  from app.vector_db import get_vector_db
20
  from app.gemini_client import get_gemini_client
@@ -8940,9 +8940,9 @@ def get_schedule():
8940
  databases = []
8941
 
8942
  schedule_db = None
8943
- for db in databases:
8944
- if isinstance(db, dict) and db.get('alias') == '์ผ์ •ํ‘œ':
8945
- schedule_db = db
8946
  break
8947
 
8948
  if not schedule_db or not schedule_db.get('id'):
@@ -8998,13 +8998,13 @@ def get_schedule():
8998
  if not date_property_name:
8999
  print("[์ผ์ •ํ‘œ ์กฐํšŒ] ๊ฒฝ๊ณ : ๋‚ ์งœ ํ•„๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ํ•„ํ„ฐ ์—†์ด ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.")
9000
 
9001
- # ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ์ค€ ๋ฒ”์œ„ ๊ณ„์‚ฐ (์ง€๋‚œ ์ฃผ ์›”์š”์ผ ~ ์ด๋ฒˆ ์ฃผ ์ผ์š”์ผ)
9002
  today = datetime.now().date()
9003
  # ์ด๋ฒˆ ์ฃผ ์›”์š”์ผ ์ฐพ๊ธฐ (์›”์š”์ผ์ด 0)
9004
  days_since_monday = today.weekday()
9005
  this_week_start = today - timedelta(days=days_since_monday)
9006
  week_start = this_week_start - timedelta(days=7) # ์ง€๋‚œ ์ฃผ ์›”์š”์ผ
9007
- week_end = this_week_start + timedelta(days=6) # ์ด๋ฒˆ ์ฃผ ์ผ์š”์ผ
9008
 
9009
  print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ๋ฒ”์œ„: {week_start.isoformat()} ~ {week_end.isoformat()}")
9010
 
@@ -9127,6 +9127,8 @@ def get_schedule():
9127
  max_retries = 3
9128
  last_exception = None
9129
  response = None
 
 
9130
 
9131
  for attempt in range(max_retries):
9132
  try:
@@ -9136,7 +9138,10 @@ def get_schedule():
9136
  json=query_data,
9137
  timeout=30 # ํƒ€์ž„์•„์›ƒ 30์ดˆ
9138
  )
9139
- break # ์„ฑ๊ณต ์‹œ ๋ฃจํ”„ ํƒˆ์ถœ
 
 
 
9140
  except (requests.exceptions.ReadTimeout, requests.exceptions.RequestException) as e:
9141
  last_exception = e
9142
  print(f"[Notion API] ์‹œ๋„ {attempt+1}/{max_retries} ์‹คํŒจ: {e}")
@@ -9147,124 +9152,193 @@ def get_schedule():
9147
  last_exception = e
9148
  break # ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๋Š” ์ฆ‰์‹œ ์ค‘๋‹จ
9149
 
9150
- if response is None:
9151
- error_msg = f"Notion API ํ˜ธ์ถœ ์ค‘ ํƒ€์ž„์•„์›ƒ ๋˜๋Š” ์—ฐ๊ฒฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. (๋งˆ์ง€๋ง‰ ์˜ค๋ฅ˜: {str(last_exception)})"
9152
- print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] {error_msg}")
9153
- return jsonify({'error': error_msg}), 504
9154
-
9155
- if response.status_code != 200:
9156
- error_data = response.json()
9157
- return jsonify({
9158
- 'error': f"Notion API ์˜ค๋ฅ˜ ({response.status_code}): {error_data.get('message', '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜')}"
9159
- }), response.status_code
9160
 
9161
- data = response.json()
9162
- all_results.extend(data.get('results', []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9163
 
9164
  # ๊ฒฐ๊ณผ ๊ฐ€๊ณต
9165
  processed_results = []
9166
  # date_property_name์€ ์œ„์—์„œ ์Šคํ‚ค๋งˆ๋ฅผ ํ†ตํ•ด ์ด๋ฏธ ๊ฒฐ์ •๋จ
9167
 
9168
  for page in all_results:
9169
- properties = page.get('properties', {})
9170
- page_data = {
9171
- 'id': page.get('id'),
9172
- 'url': page.get('url'),
9173
- 'properties': {},
9174
- 'date_value': None # ์ •๋ ฌ์šฉ ๋‚ ์งœ ๊ฐ’
9175
- }
9176
-
9177
- for prop_name, prop_val in properties.items():
9178
- ptype = prop_val.get('type')
9179
- val = None
9180
 
9181
- if ptype == 'title':
9182
- val = "".join([t.get('plain_text', '') for t in prop_val.get('title', [])])
9183
- elif ptype == 'rich_text':
9184
- val = "".join([t.get('plain_text', '') for t in prop_val.get('rich_text', [])])
9185
- elif ptype == 'select':
9186
- val = prop_val.get('select', {}).get('name') if prop_val.get('select') else None
9187
- elif ptype == 'multi_select':
9188
- val = [m.get('name') for m in prop_val.get('multi_select', [])]
9189
- elif ptype == 'number':
9190
- val = prop_val.get('number')
9191
- elif ptype == 'date':
9192
- date_obj = prop_val.get('date')
9193
- if date_obj:
9194
- start_val = date_obj.get('start')
9195
- end_val = date_obj.get('end')
9196
-
9197
- # ๊ธฐ์กด ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด val์€ start ๊ฐ’ ์œ ์ง€
9198
- val = start_val
9199
-
9200
- # Gantt ์ฐจํŠธ ๋“ฑ์„ ์œ„ํ•ด ์ƒ์„ธ ์ •๋ณด ์ €์žฅ
9201
- page_data['properties'][f"{prop_name}_detail"] = {
9202
- 'start': start_val,
9203
- 'end': end_val or start_val
9204
- }
9205
-
9206
- # ๋‚ ์งœ ํ•„๋“œ ๋ฐœ๊ฒฌ ์‹œ ์ €์žฅ (์Šคํ‚ค๋งˆ์—์„œ ๋ชป ์ฐพ์•˜์„ ๊ฒฝ์šฐ ๋Œ€๋น„)
9207
- if not date_property_name:
9208
- date_property_name = prop_name
9209
-
9210
- # ์ •๋ ฌ์šฉ ๋‚ ์งœ ๊ฐ’ ์ €์žฅ (ํ˜„์žฌ ์ฒ˜๋ฆฌ ์ค‘์ธ prop_name์ด date_property_name์ธ ๊ฒฝ์šฐ์—๋งŒ ์šฐ์„ ์ ์œผ๋กœ ์‚ฌ์šฉ)
9211
- if start_val:
9212
- try:
9213
- from datetime import datetime
9214
- current_date_val = datetime.fromisoformat(start_val.replace('Z', '+00:00'))
9215
- # date_property_name ํ•„๋“œ๋ฉด ๋ฌด์กฐ๊ฑด ์ €์žฅ, ์•„๋‹ˆ๋ฉด ๊ธฐ์กด์— ์—†์„ ๋•Œ๋งŒ ์ €์žฅ
9216
- if prop_name == date_property_name or page_data['date_value'] is None:
9217
- page_data['date_value'] = current_date_val
9218
- except:
9219
- pass
9220
- elif ptype == 'formula':
9221
- formula_val = prop_val.get('formula', {})
9222
- ftype = formula_val.get('type')
9223
- if ftype == 'date':
9224
- date_obj = formula_val.get('date')
9225
  if date_obj:
9226
  start_val = date_obj.get('start')
9227
  end_val = date_obj.get('end')
9228
- val = start_val
9229
 
 
9230
  page_data['properties'][f"{prop_name}_detail"] = {
9231
  'start': start_val,
9232
  'end': end_val or start_val
9233
  }
9234
 
 
 
 
9235
  if start_val:
9236
  try:
9237
- from datetime import datetime
9238
  current_date_val = datetime.fromisoformat(start_val.replace('Z', '+00:00'))
9239
  if prop_name == date_property_name or page_data['date_value'] is None:
9240
  page_data['date_value'] = current_date_val
9241
  except:
9242
  pass
9243
- elif ftype == 'string':
9244
- val = formula_val.get('string')
9245
- elif ftype == 'number':
9246
- val = formula_val.get('number')
9247
- elif ftype == 'boolean':
9248
- val = formula_val.get('boolean')
9249
- elif ptype == 'checkbox':
9250
- val = prop_val.get('checkbox')
9251
- elif ptype == 'url':
9252
- val = prop_val.get('url')
9253
- elif ptype == 'email':
9254
- val = prop_val.get('email')
9255
- elif ptype == 'phone_number':
9256
- val = prop_val.get('phone_number')
9257
- else:
9258
- val = f"({ptype})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9259
 
9260
- page_data['properties'][prop_name] = val
9261
-
9262
- processed_results.append(page_data)
 
 
 
 
 
 
 
9263
 
9264
- # ๋‚ ์งœ ํ•„๋“œ๊ฐ€ ์žˆ๊ณ  ํ•„ํ„ฐ๊ฐ€ ์ ์šฉ๋˜์—ˆ์ง€๋งŒ, ์„œ๋ฒ„ ์ธก์—์„œ๋„ ์ด๋ฒˆ ์ฃผ ๋ฒ”์œ„๋กœ ์ถ”๊ฐ€ ํ•„ํ„ฐ๋ง
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9265
  if date_property_name:
9266
  filtered_results = []
9267
  for item in processed_results:
 
 
 
 
 
 
 
9268
  date_str = item['properties'].get(date_property_name)
9269
  if date_str:
9270
  try:
@@ -9272,30 +9346,20 @@ def get_schedule():
9272
  if week_start <= item_date <= week_end:
9273
  filtered_results.append(item)
9274
  except:
9275
- # ๋‚ ์งœ ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ํฌํ•จํ•˜์ง€ ์•Š์Œ
9276
  pass
9277
- else:
9278
- # ๋‚ ์งœ๊ฐ€ ์—†๋Š” ํ•ญ๋ชฉ์€ ์ œ์™ธ
9279
- pass
9280
 
9281
- # filtered_results๊ฐ€ ๋น„์–ด์žˆ๋”๋ผ๋„ ์—…๋ฐ์ดํŠธํ•˜์—ฌ ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•จ
9282
  processed_results = filtered_results
9283
  if filtered_results:
9284
  print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ์„œ๋ฒ„ ์ธก ํ•„ํ„ฐ๋ง ํ›„: {len(processed_results)}๊ฐœ ํ•ญ๋ชฉ")
9285
  else:
9286
- print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ๊ฒฝ๊ณ : ์ด๋ฒˆ ์ฃผ ๋ฒ”์œ„์— ํ•ด๋‹นํ•˜๋Š” ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค. (์›๋ž˜ {len(all_results)}๊ฐœ ์ค‘ 0๊ฐœ ๋งค์นญ)")
9287
 
9288
  # ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ์ •๋ ฌ (์ตœ๊ทผ ๋‚ ์งœ๊ฐ€ ๋จผ์ €)
9289
- # date_value๊ฐ€ None์ธ ํ•ญ๋ชฉ์€ ๋งจ ๋’ค๋กœ
9290
- processed_results.sort(key=lambda x: (x['date_value'] is None, x['date_value'] or datetime.min), reverse=True)
9291
 
9292
  # has_more ๊ฐ’ ํ™•์ธ ๋ฐ ๋กœ๊ทธ ์ถœ๋ ฅ
9293
  has_more_value = data.get('has_more', False)
9294
  next_cursor_value = data.get('next_cursor')
9295
- print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] has_more ๊ฐ’: {has_more_value} (ํƒ€์ž…: {type(has_more_value).__name__})")
9296
- print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] next_cursor ๊ฐ’: {next_cursor_value}")
9297
- print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] Notion API ์‘๋‹ต์˜ results ๊ฐœ์ˆ˜: {len(data.get('results', []))}")
9298
- print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ์ฒ˜๋ฆฌ๋œ results ๊ฐœ์ˆ˜: {len(processed_results)}")
9299
 
9300
  filter_desc = []
9301
  if date_property_name:
@@ -9311,6 +9375,8 @@ def get_schedule():
9311
  'date_property': date_property_name,
9312
  'has_more': has_more_value,
9313
  'next_cursor': next_cursor_value,
 
 
9314
  'query_info': {
9315
  'filter': " & ".join(filter_desc) if filter_desc else '๋ชจ๋“  ๋ฐ์ดํ„ฐ (ํ•„ํ„ฐ ์—†์Œ)',
9316
  'sort': f"'{date_property_name}' ํ•„๋“œ ๊ธฐ์ค€ ๋‚ด๋ฆผ์ฐจ์ˆœ (์ตœ์‹ ์ˆœ)" if date_property_name else '์ •๋ ฌ ํ•„๋“œ ์—†์Œ'
@@ -9351,13 +9417,13 @@ def update_notion_db_aliases():
9351
  # ๋ณ„์นญ ์—…๋ฐ์ดํŠธ
9352
  alias_map = {a['id']: a.get('alias') for a in aliases if isinstance(a, dict) and a.get('id')}
9353
 
9354
- for db in databases:
9355
- if isinstance(db, dict) and db.get('id') in alias_map:
9356
- alias_value = alias_map[db['id']]
9357
  if alias_value:
9358
- db['alias'] = alias_value
9359
- elif 'alias' in db:
9360
- del db['alias'] # ๋นˆ ๋ณ„์นญ์€ ์ œ๊ฑฐ
9361
 
9362
  # ์ €์žฅ
9363
  SystemConfig.set_config(
 
14
  WebtoonWBSAnalysis, WebtoonWBSJob,
15
  WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
16
  WebtoonStageKeyMapping, WebtoonMilestone, WebtoonMilestoneManager,
17
+ Notice, NoticeRecipient, NotionScheduleCache
18
  )
19
  from app.vector_db import get_vector_db
20
  from app.gemini_client import get_gemini_client
 
8940
  databases = []
8941
 
8942
  schedule_db = None
8943
+ for db_info in databases:
8944
+ if isinstance(db_info, dict) and db_info.get('alias') == '์ผ์ •ํ‘œ':
8945
+ schedule_db = db_info
8946
  break
8947
 
8948
  if not schedule_db or not schedule_db.get('id'):
 
8998
  if not date_property_name:
8999
  print("[์ผ์ •ํ‘œ ์กฐํšŒ] ๊ฒฝ๊ณ : ๋‚ ์งœ ํ•„๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ํ•„ํ„ฐ ์—†์ด ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.")
9000
 
9001
+ # ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ์ค€ ๋ฒ”์œ„ ๊ณ„์‚ฐ (์ง€๋‚œ ์ฃผ ์›”์š”์ผ ~ ๋‹ค์Œ ์ฃผ ์ผ์š”์ผ)
9002
  today = datetime.now().date()
9003
  # ์ด๋ฒˆ ์ฃผ ์›”์š”์ผ ์ฐพ๊ธฐ (์›”์š”์ผ์ด 0)
9004
  days_since_monday = today.weekday()
9005
  this_week_start = today - timedelta(days=days_since_monday)
9006
  week_start = this_week_start - timedelta(days=7) # ์ง€๋‚œ ์ฃผ ์›”์š”์ผ
9007
+ week_end = this_week_start + timedelta(days=13) # ๋‹ค์Œ ์ฃผ ์ผ์š”์ผ
9008
 
9009
  print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ๋ฒ”์œ„: {week_start.isoformat()} ~ {week_end.isoformat()}")
9010
 
 
9127
  max_retries = 3
9128
  last_exception = None
9129
  response = None
9130
+ is_from_cache = False
9131
+ error_message = None
9132
 
9133
  for attempt in range(max_retries):
9134
  try:
 
9138
  json=query_data,
9139
  timeout=30 # ํƒ€์ž„์•„์›ƒ 30์ดˆ
9140
  )
9141
+ if response.status_code == 200:
9142
+ break # ์„ฑ๊ณต ์‹œ ๋ฃจํ”„ ํƒˆ์ถœ
9143
+ else:
9144
+ last_exception = Exception(f"API Error {response.status_code}: {response.text}")
9145
  except (requests.exceptions.ReadTimeout, requests.exceptions.RequestException) as e:
9146
  last_exception = e
9147
  print(f"[Notion API] ์‹œ๋„ {attempt+1}/{max_retries} ์‹คํŒจ: {e}")
 
9152
  last_exception = e
9153
  break # ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๋Š” ์ฆ‰์‹œ ์ค‘๋‹จ
9154
 
9155
+ data = None
9156
+ all_results = []
 
 
 
 
 
 
 
 
9157
 
9158
+ if response is not None and response.status_code == 200:
9159
+ data = response.json()
9160
+ all_results.extend(data.get('results', []))
9161
+ else:
9162
+ # Notion API ์‹คํŒจ ์‹œ ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
9163
+ print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] Notion API ํ˜ธ์ถœ ์‹คํŒจ. ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. (์˜ค๋ฅ˜: {last_exception})")
9164
+ error_message = f"Notion API ์—ฐ๊ฒฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ €์žฅ๋œ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค. (์˜ค๋ฅ˜: {str(last_exception)})"
9165
+ is_from_cache = True
9166
+
9167
+ # ์บ์‹œ๋œ ๋ชจ๋“  ๋ฐ์ดํ„ฐ ์กฐํšŒ (๋ฉ”๋ชจ๋ฆฌ์—์„œ ๋‚ ์งœ ํ•„ํ„ฐ๋ง)
9168
+ cache_items = NotionScheduleCache.query.all()
9169
+ for item in cache_items:
9170
+ all_results.append(item.to_dict())
9171
+
9172
+ # ์บ์‹œ ๋ฐ์ดํ„ฐ์˜ ๊ฒฝ์šฐ 'data' ๊ฐ์ฒด ๋ชจ์กฐ ์ƒ์„ฑ
9173
+ data = {
9174
+ 'results': all_results,
9175
+ 'has_more': False,
9176
+ 'next_cursor': None
9177
+ }
9178
 
9179
  # ๊ฒฐ๊ณผ ๊ฐ€๊ณต
9180
  processed_results = []
9181
  # date_property_name์€ ์œ„์—์„œ ์Šคํ‚ค๋งˆ๋ฅผ ํ†ตํ•ด ์ด๋ฏธ ๊ฒฐ์ •๋จ
9182
 
9183
  for page in all_results:
9184
+ # ์บ์‹œ ๋ฐ์ดํ„ฐ(์ด๋ฏธ ๊ฐ€๊ณต๋จ)์™€ ๋…ธ์…˜ ์›๋ณธ ๋ฐ์ดํ„ฐ(๊ฐ€๊ณต ํ•„์š”) ๊ตฌ๋ถ„
9185
+ # ๋…ธ์…˜ ์›๋ณธ ๋ฐ์ดํ„ฐ๋Š” 'object' ํ•„๋“œ๊ฐ€ 'page'์ž…๋‹ˆ๋‹ค.
9186
+ if page.get('object') == 'page':
9187
+ # ๋…ธ์…˜ ์›๋ณธ ๋ฐ์ดํ„ฐ ๊ฐ€๊ณต ์‹œ์ž‘
9188
+ properties = page.get('properties', {})
9189
+ page_data = {
9190
+ 'id': page.get('id'),
9191
+ 'url': page.get('url'),
9192
+ 'properties': {},
9193
+ 'date_value': None # ์ •๋ ฌ์šฉ ๋‚ ์งœ ๊ฐ’
9194
+ }
9195
 
9196
+ for prop_name, prop_val in properties.items():
9197
+ ptype = prop_val.get('type')
9198
+ val = None
9199
+
9200
+ if ptype == 'title':
9201
+ val = "".join([t.get('plain_text', '') for t in prop_val.get('title', [])])
9202
+ elif ptype == 'rich_text':
9203
+ val = "".join([t.get('plain_text', '') for t in prop_val.get('rich_text', [])])
9204
+ elif ptype == 'select':
9205
+ val = prop_val.get('select', {}).get('name') if prop_val.get('select') else None
9206
+ elif ptype == 'multi_select':
9207
+ val = [m.get('name') for m in prop_val.get('multi_select', [])]
9208
+ elif ptype == 'number':
9209
+ val = prop_val.get('number')
9210
+ elif ptype == 'date':
9211
+ date_obj = prop_val.get('date')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9212
  if date_obj:
9213
  start_val = date_obj.get('start')
9214
  end_val = date_obj.get('end')
 
9215
 
9216
+ val = start_val
9217
  page_data['properties'][f"{prop_name}_detail"] = {
9218
  'start': start_val,
9219
  'end': end_val or start_val
9220
  }
9221
 
9222
+ if not date_property_name:
9223
+ date_property_name = prop_name
9224
+
9225
  if start_val:
9226
  try:
 
9227
  current_date_val = datetime.fromisoformat(start_val.replace('Z', '+00:00'))
9228
  if prop_name == date_property_name or page_data['date_value'] is None:
9229
  page_data['date_value'] = current_date_val
9230
  except:
9231
  pass
9232
+ elif ptype == 'formula':
9233
+ formula_val = prop_val.get('formula', {})
9234
+ ftype = formula_val.get('type')
9235
+ if ftype == 'date':
9236
+ date_obj = formula_val.get('date')
9237
+ if date_obj:
9238
+ start_val = date_obj.get('start')
9239
+ end_val = date_obj.get('end')
9240
+ val = start_val
9241
+
9242
+ page_data['properties'][f"{prop_name}_detail"] = {
9243
+ 'start': start_val,
9244
+ 'end': end_val or start_val
9245
+ }
9246
+
9247
+ if start_val:
9248
+ try:
9249
+ current_date_val = datetime.fromisoformat(start_val.replace('Z', '+00:00'))
9250
+ if prop_name == date_property_name or page_data['date_value'] is None:
9251
+ page_data['date_value'] = current_date_val
9252
+ except:
9253
+ pass
9254
+ elif ftype == 'string':
9255
+ val = formula_val.get('string')
9256
+ elif ftype == 'number':
9257
+ val = formula_val.get('number')
9258
+ elif ftype == 'boolean':
9259
+ val = formula_val.get('boolean')
9260
+ elif ptype == 'checkbox':
9261
+ val = prop_val.get('checkbox')
9262
+ elif ptype == 'url':
9263
+ val = prop_val.get('url')
9264
+ elif ptype == 'email':
9265
+ val = prop_val.get('email')
9266
+ elif ptype == 'phone_number':
9267
+ val = prop_val.get('phone_number')
9268
+ else:
9269
+ val = f"({ptype})"
9270
+
9271
+ page_data['properties'][prop_name] = val
9272
 
9273
+ processed_results.append(page_data)
9274
+ else:
9275
+ # ์ด๋ฏธ ๊ฐ€๊ณต๋œ ๋ฐ์ดํ„ฐ (์บ์‹œ์—์„œ ์˜จ ๊ฒฝ์šฐ)
9276
+ # date_value๊ฐ€ datetime ๊ฐ์ฒด๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋ณต์›
9277
+ if page.get('date_value') and isinstance(page['date_value'], str):
9278
+ try:
9279
+ page['date_value'] = datetime.fromisoformat(page['date_value'].replace('Z', '+00:00'))
9280
+ except:
9281
+ page['date_value'] = None
9282
+ processed_results.append(page)
9283
 
9284
+ # Notion์—์„œ ์„ฑ๊ณต์ ์œผ๋กœ ๊ฐ€์ ธ์˜จ ๊ฒฝ์šฐ ์บ์‹œ์— ์ €์žฅ/์—…๋ฐ์ดํŠธ
9285
+ if not is_from_cache:
9286
+ try:
9287
+ for page_data in processed_results:
9288
+ notion_id = page_data.get('id')
9289
+ # '์ด๋ฆ„' ์†์„ฑ์—์„œ ์ด๋ฆ„ ์ถ”์ถœ (๋‹ด๋‹น์ž ํ•„ํ„ฐ๋ง์šฉ)
9290
+ name = page_data['properties'].get('์ด๋ฆ„', '-')
9291
+
9292
+ # ๋‚ ์งœ ์ถ”์ถœ
9293
+ start_date_s = None
9294
+ end_date_s = None
9295
+ if date_property_name:
9296
+ detail = page_data['properties'].get(f"{date_property_name}_detail", {})
9297
+ start_date_s = detail.get('start')
9298
+ end_date_s = detail.get('end') or start_date_s
9299
+
9300
+ # date_value๋Š” JSON ์ €์žฅ์„ ์œ„ํ•ด ๋ฌธ์ž์—ด๋กœ ์ž„์‹œ ๋ณ€ํ™˜
9301
+ save_data = page_data.copy()
9302
+ if save_data.get('date_value') and isinstance(save_data['date_value'], datetime):
9303
+ save_data['date_value'] = save_data['date_value'].isoformat()
9304
+
9305
+ new_json = json.dumps(save_data, ensure_ascii=False)
9306
+
9307
+ cached_item = NotionScheduleCache.query.filter_by(notion_id=notion_id).first()
9308
+ if not cached_item:
9309
+ cached_item = NotionScheduleCache(
9310
+ notion_id=notion_id,
9311
+ name=name,
9312
+ start_date=start_date_s,
9313
+ end_date=end_date_s,
9314
+ data_json=new_json
9315
+ )
9316
+ db.session.add(cached_item)
9317
+ else:
9318
+ # ์ˆ˜์ • ์‚ฌํ•ญ์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ์—…๋ฐ์ดํŠธ
9319
+ if cached_item.data_json != new_json:
9320
+ cached_item.name = name
9321
+ cached_item.start_date = start_date_s
9322
+ cached_item.end_date = end_date_s
9323
+ cached_item.data_json = new_json
9324
+
9325
+ db.session.commit()
9326
+ print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] {len(processed_results)}๊ฐœ ํ•ญ๋ชฉ ์บ์‹œ ๋™๊ธฐํ™” ์™„๋ฃŒ")
9327
+ except Exception as cache_err:
9328
+ db.session.rollback()
9329
+ print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ์บ์‹œ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜: {cache_err}")
9330
+
9331
+ # ์„œ๋ฒ„ ์ธก ํ•„ํ„ฐ๋ง (์บ์‹œ ๋ฐ์ดํ„ฐ ํฌํ•จ)
9332
  if date_property_name:
9333
  filtered_results = []
9334
  for item in processed_results:
9335
+ # ์ด๋ฆ„ ํ•„ํ„ฐ ์ ์šฉ (์บ์‹œ์—์„œ ๊ฐ€์ ธ์˜จ ๊ฒฝ์šฐ Notion API ํ•„ํ„ฐ๊ฐ€ ์•ˆ๋จนํ˜”์„ ์ˆ˜ ์žˆ์Œ)
9336
+ if name_filter_vals:
9337
+ item_name = str(item['properties'].get('์ด๋ฆ„', '') or '')
9338
+ if not any(val in item_name for val in name_filter_vals):
9339
+ continue
9340
+
9341
+ # ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ
9342
  date_str = item['properties'].get(date_property_name)
9343
  if date_str:
9344
  try:
 
9346
  if week_start <= item_date <= week_end:
9347
  filtered_results.append(item)
9348
  except:
 
9349
  pass
 
 
 
9350
 
 
9351
  processed_results = filtered_results
9352
  if filtered_results:
9353
  print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ์„œ๋ฒ„ ์ธก ํ•„ํ„ฐ๋ง ํ›„: {len(processed_results)}๊ฐœ ํ•ญ๋ชฉ")
9354
  else:
9355
+ print(f"[์ผ์ •ํ‘œ ์กฐํšŒ] ๊ฒฝ๊ณ : ์ด๋ฒˆ ์ฃผ ๋ฒ”์œ„์— ํ•ด๋‹นํ•˜๋Š” ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.")
9356
 
9357
  # ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ์ •๋ ฌ (์ตœ๊ทผ ๋‚ ์งœ๊ฐ€ ๋จผ์ €)
9358
+ processed_results.sort(key=lambda x: (x.get('date_value') is None, x.get('date_value') or datetime.min), reverse=True)
 
9359
 
9360
  # has_more ๊ฐ’ ํ™•์ธ ๋ฐ ๋กœ๊ทธ ์ถœ๋ ฅ
9361
  has_more_value = data.get('has_more', False)
9362
  next_cursor_value = data.get('next_cursor')
 
 
 
 
9363
 
9364
  filter_desc = []
9365
  if date_property_name:
 
9375
  'date_property': date_property_name,
9376
  'has_more': has_more_value,
9377
  'next_cursor': next_cursor_value,
9378
+ 'is_from_cache': is_from_cache,
9379
+ 'error': error_message,
9380
  'query_info': {
9381
  'filter': " & ".join(filter_desc) if filter_desc else '๋ชจ๋“  ๋ฐ์ดํ„ฐ (ํ•„ํ„ฐ ์—†์Œ)',
9382
  'sort': f"'{date_property_name}' ํ•„๋“œ ๊ธฐ์ค€ ๋‚ด๋ฆผ์ฐจ์ˆœ (์ตœ์‹ ์ˆœ)" if date_property_name else '์ •๋ ฌ ํ•„๋“œ ์—†์Œ'
 
9417
  # ๋ณ„์นญ ์—…๋ฐ์ดํŠธ
9418
  alias_map = {a['id']: a.get('alias') for a in aliases if isinstance(a, dict) and a.get('id')}
9419
 
9420
+ for db_info in databases:
9421
+ if isinstance(db_info, dict) and db_info.get('id') in alias_map:
9422
+ alias_value = alias_map[db_info['id']]
9423
  if alias_value:
9424
+ db_info['alias'] = alias_value
9425
+ elif 'alias' in db_info:
9426
+ del db_info['alias'] # ๋นˆ ๋ณ„์นญ์€ ์ œ๊ฑฐ
9427
 
9428
  # ์ €์žฅ
9429
  SystemConfig.set_config(
check_has_more.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ์ผ์ •ํ‘œ API์˜ has_more ๊ฐ’์„ ํ™•์ธํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ
3
+ """
4
+ import requests
5
+ import json
6
+
7
+ # ์„œ๋ฒ„ URL
8
+ base_url = "http://localhost:5001"
9
+
10
+ # ์„ธ์…˜ ์ƒ์„ฑ (์ฟ ํ‚ค ์œ ์ง€์šฉ)
11
+ session = requests.Session()
12
+
13
+ # ๋กœ๊ทธ์ธ (ํ•„์š”ํ•œ ๊ฒฝ์šฐ)
14
+ # ์‹ค์ œ ์‚ฌ์šฉ์ž๋ช…๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋กœ๊ทธ์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค
15
+ print("=" * 80)
16
+ print("์ผ์ •ํ‘œ API์˜ has_more ๊ฐ’ ํ™•์ธ")
17
+ print("=" * 80)
18
+ print("\nโš ๏ธ ์ด ์Šคํฌ๋ฆฝํŠธ๋Š” ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
19
+ print("๋ธŒ๋ผ์šฐ์ €์—์„œ ๋กœ๊ทธ์ธํ•œ ํ›„ ์ฟ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜,")
20
+ print("์ง์ ‘ ๋ธŒ๋ผ์šฐ์ € ๊ฐœ๋ฐœ์ž ๋„๊ตฌ์˜ Network ํƒญ์—์„œ ํ™•์ธํ•˜์„ธ์š”.\n")
21
+
22
+ # API ์—”๋“œํฌ์ธํŠธ
23
+ api_url = f"{base_url}/api/admin/webtoon/agent/schedule"
24
+
25
+ print(f"API URL: {api_url}")
26
+ print("\n๋ธŒ๋ผ์šฐ์ €์—์„œ ๋‹ค์Œ ๋‹จ๊ณ„๋ฅผ ๋”ฐ๋ผ์ฃผ์„ธ์š”:")
27
+ print("1. http://localhost:5001/admin/agent/schedule ํŽ˜์ด์ง€๋ฅผ ์—ฝ๋‹ˆ๋‹ค")
28
+ print("2. ๋กœ๊ทธ์ธํ•ฉ๋‹ˆ๋‹ค (ํ•„์š”ํ•œ ๊ฒฝ์šฐ)")
29
+ print("3. '์ƒˆ๋กœ๊ณ ์นจ' ๋ฒ„ํŠผ์„ ํด๋ฆญํ•ฉ๋‹ˆ๋‹ค")
30
+ print("4. ๋ธŒ๋ผ์šฐ์ € ๊ฐœ๋ฐœ์ž ๋„๊ตฌ(F12)๋ฅผ ์—ฝ๋‹ˆ๋‹ค")
31
+ print("5. Network ํƒญ์—์„œ '/api/admin/webtoon/agent/schedule' ์š”์ฒญ์„ ์ฐพ์Šต๋‹ˆ๋‹ค")
32
+ print("6. Response ํƒญ์—์„œ 'has_more' ๊ฐ’์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค")
33
+ print("\n๋˜๋Š” ์ฝ˜์†”์—์„œ ๋‹ค์Œ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜์„ธ์š”:")
34
+ print("fetch('/api/admin/webtoon/agent/schedule').then(r => r.json()).then(d => console.log('has_more:', d.has_more))")
35
+
check_notion_token.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import sys
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ path์— ์ถ”๊ฐ€
7
+ project_root = Path(__file__).parent
8
+ sys.path.append(str(project_root))
9
+
10
+ from app import create_app
11
+ from app.database import db, SystemConfig
12
+
13
+ def check_notion_config():
14
+ app = create_app()
15
+ with app.app_context():
16
+ keys = ['notion_integration_token', 'notion_database_id', 'notion_database_name']
17
+ print("-" * 50)
18
+ print("Notion ์„ค์ •๊ฐ’ ์ƒ์„ธ ํ™•์ธ")
19
+ print("-" * 50)
20
+
21
+ for key in keys:
22
+ config = SystemConfig.query.filter_by(key=key).first()
23
+ if config:
24
+ val = config.value
25
+ print(f"[{key}]")
26
+ print(f" - ๊ฐ’: {val}")
27
+ print(f" - ๊ธธ์ด: {len(val)}์ž")
28
+ else:
29
+ print(f"[{key}] ์„ค์ •๋˜์ง€ ์•Š์Œ")
30
+ print("-" * 50)
31
+
32
+ if __name__ == "__main__":
33
+ check_notion_config()
debug_dbs.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import sqlite3
3
+ import os
4
+
5
+ dbs = ['instance/finance_analysis.db', 'instance/soy_nv_ai.db', 'instance/database.db']
6
+
7
+ for db_path in dbs:
8
+ if not os.path.exists(db_path):
9
+ print(f"File not found: {db_path}")
10
+ continue
11
+
12
+ print(f"\n--- Checking {db_path} ---")
13
+ try:
14
+ conn = sqlite3.connect(db_path)
15
+ cursor = conn.cursor()
16
+
17
+ # ํ…Œ์ด๋ธ” ์กด์žฌ ํ™•์ธ
18
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_config'")
19
+ if not cursor.fetchone():
20
+ print("Table 'system_config' not found.")
21
+ conn.close()
22
+ continue
23
+
24
+ cursor.execute("SELECT key, value FROM system_config WHERE key LIKE 'notion_%'")
25
+ rows = cursor.fetchall()
26
+ if not rows:
27
+ print("No notion configs found.")
28
+ for row in rows:
29
+ key, val = row
30
+ if key == 'notion_integration_token' and val:
31
+ masked = val[:15] + "..." + val[-5:] if len(val) > 20 else val
32
+ print(f"{key}: {masked} (Len: {len(val)})")
33
+ else:
34
+ print(f"{key}: {val}")
35
+ conn.close()
36
+ except Exception as e:
37
+ print(f"Error checking {db_path}: {e}")
38
+
force_update_menu.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app import create_app
2
+ from app.database import db, SystemConfig
3
+ import json
4
+ from app.routes import merge_admin_menu_defaults, get_default_admin_menu
5
+
6
+ def update_menu():
7
+ app = create_app()
8
+ with app.app_context():
9
+ raw = SystemConfig.get_config('admin_menu_config_v1')
10
+ if not raw:
11
+ # ์„ค์ •์ด ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’์„ ์ €์žฅ
12
+ default_menu = get_default_admin_menu()
13
+ SystemConfig.set_config('admin_menu_config_v1', json.dumps(default_menu, ensure_ascii=False, indent=2))
14
+ print("Default menu saved to DB")
15
+ return
16
+
17
+ obj = json.loads(raw) if isinstance(raw, str) else raw
18
+ new_obj = merge_admin_menu_defaults(obj)
19
+
20
+ # ์—์ด์ „ํŠธ ์„น์…˜์ด ํฌํ•จ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ
21
+ has_agent = any(s.get('label') == '์—์ด์ „ํŠธ' for s in new_obj.get('sections', []))
22
+ if not has_agent:
23
+ # ๊ฐ•์ œ๋กœ ์ถ”๊ฐ€
24
+ base = get_default_admin_menu()
25
+ agent_section = next((s for s in base.get('sections', []) if s.get('label') == '์—์ด์ „ํŠธ'), None)
26
+ if agent_section:
27
+ new_obj['sections'].append(agent_section)
28
+ print("Agent section forcibly added")
29
+
30
+ SystemConfig.set_config('admin_menu_config_v1', json.dumps(new_obj, ensure_ascii=False, indent=2))
31
+ print("Menu updated and saved to DB")
32
+
33
+ if __name__ == "__main__":
34
+ update_menu()
35
+
36
+
37
+
38
+
39
+
40
+
41
+
restart_server.ps1 CHANGED
@@ -47,10 +47,10 @@ Start-Sleep -Seconds 5
47
  # ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ
48
  $port = netstat -ano | findstr ":5001"
49
  if ($port) {
50
- Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] โœ“ ์„œ๋ฒ„ ์žฌ๋ถ€ํŒ… ์™„๋ฃŒ!" -ForegroundColor Green
51
  Write-Host "ํฌํŠธ 5001์—์„œ ๋ฆฌ์Šค๋‹ ์ค‘" -ForegroundColor Cyan
52
  $port
53
  } else {
54
- Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] โš  ์„œ๋ฒ„ ์‹œ์ž‘ ์‹คํŒจ ๋˜๋Š” ์•„์ง ์‹œ์ž‘ ์ค‘..." -ForegroundColor Yellow
55
  }
56
 
 
47
  # ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ
48
  $port = netstat -ano | findstr ":5001"
49
  if ($port) {
50
+ Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ์„œ๋ฒ„ ์žฌ๋ถ€ํŒ… ์™„๋ฃŒ!" -ForegroundColor Green
51
  Write-Host "ํฌํŠธ 5001์—์„œ ๋ฆฌ์Šค๋‹ ์ค‘" -ForegroundColor Cyan
52
  $port
53
  } else {
54
+ Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ์„œ๋ฒ„ ์‹œ์ž‘ ์‹คํŒจ ๋˜๋Š” ์•„์ง ์‹œ์ž‘ ์ค‘..." -ForegroundColor Yellow
55
  }
56
 
start_server_background.ps1 CHANGED
@@ -4,85 +4,32 @@ $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
4
  Set-Location $scriptPath
5
 
6
  # ๋กœ๊ทธ ํŒŒ์ผ ๊ฒฝ๋กœ
7
- $logFile = Join-Path $scriptPath "server.log"
8
 
9
  function Write-Log {
10
  param([string]$Message)
11
  $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
12
  $logMessage = "[$timestamp] $Message"
13
  Write-Host $logMessage
 
14
  Add-Content -Path $logFile -Value $logMessage
15
  }
16
 
17
- function Start-ServerProcess {
18
- Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์‹œ์ž‘ ์ค‘..."
19
-
20
- $processInfo = New-Object System.Diagnostics.ProcessStartInfo
21
- $processInfo.FileName = "python"
22
- $processInfo.Arguments = "run.py"
23
- $processInfo.WorkingDirectory = $scriptPath
24
- $processInfo.UseShellExecute = $false
25
- $processInfo.RedirectStandardOutput = $true
26
- $processInfo.RedirectStandardError = $true
27
- $processInfo.CreateNoWindow = $true
28
-
29
- $process = New-Object System.Diagnostics.Process
30
- $process.StartInfo = $processInfo
31
-
32
- # ์ถœ๋ ฅ ๋ฆฌ๋‹ค์ด๋ ‰์…˜
33
- $process.add_OutputDataReceived({
34
- param($sender, $e)
35
- if ($e.Data) {
36
- Write-Log $e.Data
37
- }
38
- })
39
-
40
- $process.add_ErrorDataReceived({
41
- param($sender, $e)
42
- if ($e.Data) {
43
- Write-Log "ERROR: $($e.Data)"
44
- }
45
- })
46
-
47
- $process.Start() | Out-Null
48
- $process.BeginOutputReadLine()
49
- $process.BeginErrorReadLine()
50
-
51
- return $process
52
- }
53
-
54
- # ๋ฉ”์ธ ๋ฃจํ”„
55
  Write-Log "=== ์„œ๋ฒ„ ์ž๋™ ์žฌ์‹œ์ž‘ ์Šคํฌ๋ฆฝํŠธ ์‹œ์ž‘ ==="
56
 
 
 
57
  while ($true) {
58
- $process = $null
59
  try {
60
- $process = Start-ServerProcess
61
- Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์‹œ์ž‘๋จ (PID: $($process.Id))"
62
-
63
- # ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ข…๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
64
- $process.WaitForExit()
65
-
66
- $exitCode = $process.ExitCode
67
- Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ๋จ (Exit Code: $exitCode)"
68
-
69
- # ํ”„๋กœ์„ธ์Šค ์ •๋ฆฌ
70
- if (!$process.HasExited) {
71
- $process.Kill()
72
- }
73
- $process.Dispose()
74
  }
75
  catch {
76
  Write-Log "์˜ค๋ฅ˜ ๋ฐœ์ƒ: $_"
77
- if ($process -and !$process.HasExited) {
78
- try {
79
- $process.Kill()
80
- } catch {}
81
- }
82
  }
83
 
84
  Write-Log "5์ดˆ ํ›„ ์„œ๋ฒ„๋ฅผ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..."
85
  Start-Sleep -Seconds 5
86
  }
87
-
88
-
 
4
  Set-Location $scriptPath
5
 
6
  # ๋กœ๊ทธ ํŒŒ์ผ ๊ฒฝ๋กœ
7
+ $logFile = Join-Path $scriptPath "logs\server_startup.log"
8
 
9
  function Write-Log {
10
  param([string]$Message)
11
  $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
12
  $logMessage = "[$timestamp] $Message"
13
  Write-Host $logMessage
14
+ if (!(Test-Path (Split-Path $logFile))) { New-Item -ItemType Directory -Path (Split-Path $logFile) -Force }
15
  Add-Content -Path $logFile -Value $logMessage
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  Write-Log "=== ์„œ๋ฒ„ ์ž๋™ ์žฌ์‹œ์ž‘ ์Šคํฌ๋ฆฝํŠธ ์‹œ์ž‘ ==="
19
 
20
+ $pythonPath = "$scriptPath\venv\Scripts\python.exe"
21
+
22
  while ($true) {
23
+ Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์‹œ์ž‘ ์ค‘..."
24
  try {
25
+ # ๊ฐ„๋‹จํ•˜๊ฒŒ Start-Process๋กœ ์‹คํ–‰ํ•˜๊ณ  ๋Œ€๊ธฐ
26
+ $process = Start-Process -FilePath $pythonPath -ArgumentList "run.py" -WorkingDirectory $scriptPath -PassThru -NoNewWindow -Wait
27
+ Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ๋จ (Exit Code: $($process.ExitCode))"
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
  catch {
30
  Write-Log "์˜ค๋ฅ˜ ๋ฐœ์ƒ: $_"
 
 
 
 
 
31
  }
32
 
33
  Write-Log "5์ดˆ ํ›„ ์„œ๋ฒ„๋ฅผ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..."
34
  Start-Sleep -Seconds 5
35
  }
 
 
templates/admin_settings.html CHANGED
@@ -804,10 +804,11 @@
804
 
805
  let html = '';
806
  currentNotionDatabases.forEach((db, index) => {
 
807
  html += `
808
  <div style="display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: #fff; border: 1px solid #dadce0; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">
809
  <div style="flex: 1; min-width: 0;">
810
- <div style="font-weight: 600; font-size: 13px; color: #202124; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${db.name}</div>
811
  <div style="font-size: 11px; color: #5f6368; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${db.id}</div>
812
  </div>
813
  <button onclick="removeDatabase(${index})" style="background: none; border: none; color: #d93025; cursor: pointer; padding: 4px; font-size: 18px; line-height: 1;" title="์ œ๊ฑฐ">ร—</button>
@@ -905,10 +906,11 @@
905
 
906
  let html = '<div style="padding: 8px; border-bottom: 1px solid #eee; font-weight: 600; font-size: 12px; color: #1a73e8; background: #f8f9fa; display: flex; justify-content: space-between; align-items: center;"><span>์ถ”๊ฐ€ํ•  DB๋ฅผ ์„ ํƒํ•˜์„ธ์š”:</span><button onclick="document.getElementById(\'notionDatabaseList\').style.display=\'none\'" style="background:none; border:none; color:#5f6368; cursor:pointer;">๋‹ซ๊ธฐ</button></div>';
907
  data.databases.forEach(db => {
 
908
  html += `
909
  <div class="db-item" onclick="selectNotionDatabase('${db.id}', '${db.title.replace(/'/g, "\\'")}')"
910
  style="padding: 10px 12px; cursor: pointer; border-bottom: 1px solid #f1f3f4; font-size: 13px; transition: background 0.2s;">
911
- <div style="font-weight: 500;">${db.title}</div>
912
  <div style="font-size: 11px; color: #5f6368; font-family: monospace;">${db.id}</div>
913
  </div>
914
  `;
 
804
 
805
  let html = '';
806
  currentNotionDatabases.forEach((db, index) => {
807
+ const aliasText = db.alias ? ` <span style="color: #1a73e8; font-weight: 500;">[๋ณ„์นญ: ${escapeHtml(db.alias)}]</span>` : '';
808
  html += `
809
  <div style="display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: #fff; border: 1px solid #dadce0; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">
810
  <div style="flex: 1; min-width: 0;">
811
+ <div style="font-weight: 600; font-size: 13px; color: #202124; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(db.name)}${aliasText}</div>
812
  <div style="font-size: 11px; color: #5f6368; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${db.id}</div>
813
  </div>
814
  <button onclick="removeDatabase(${index})" style="background: none; border: none; color: #d93025; cursor: pointer; padding: 4px; font-size: 18px; line-height: 1;" title="์ œ๊ฑฐ">ร—</button>
 
906
 
907
  let html = '<div style="padding: 8px; border-bottom: 1px solid #eee; font-weight: 600; font-size: 12px; color: #1a73e8; background: #f8f9fa; display: flex; justify-content: space-between; align-items: center;"><span>์ถ”๊ฐ€ํ•  DB๋ฅผ ์„ ํƒํ•˜์„ธ์š”:</span><button onclick="document.getElementById(\'notionDatabaseList\').style.display=\'none\'" style="background:none; border:none; color:#5f6368; cursor:pointer;">๋‹ซ๊ธฐ</button></div>';
908
  data.databases.forEach(db => {
909
+ const aliasText = db.alias ? ` <span style="color: #1a73e8; font-weight: 500;">[๋ณ„์นญ: ${escapeHtml(db.alias)}]</span>` : '';
910
  html += `
911
  <div class="db-item" onclick="selectNotionDatabase('${db.id}', '${db.title.replace(/'/g, "\\'")}')"
912
  style="padding: 10px 12px; cursor: pointer; border-bottom: 1px solid #f1f3f4; font-size: 13px; transition: background 0.2s;">
913
+ <div style="font-weight: 500;">${escapeHtml(db.title)}${aliasText}</div>
914
  <div style="font-size: 11px; color: #5f6368; font-family: monospace;">${db.id}</div>
915
  </div>
916
  `;
templates/agent_notion_db.html CHANGED
@@ -193,8 +193,10 @@
193
 
194
  if (!response.ok) throw new Error(data.error || '์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
195
 
 
 
196
  selector.innerHTML = '';
197
- if (!data.databases || data.databases.length === 0) {
198
  const option = document.createElement('option');
199
  option.value = '';
200
  option.textContent = '์„ค์ •๋œ DB ์—†์Œ (์—ฐ๋™ ์„ค์ • ํ•„์š”)';
@@ -202,16 +204,15 @@
202
  return;
203
  }
204
 
205
- data.databases.forEach(db => {
206
  const option = document.createElement('option');
207
  option.value = db.id;
208
- option.textContent = db.name;
 
 
209
  selector.appendChild(option);
210
  });
211
 
212
- // ์ฒซ ๋ฒˆ์งธ DB ์ž๋™ ์กฐํšŒ ์‹œ๋„ (์„ ํƒ ์‚ฌํ•ญ)
213
- // fetchNotionData();
214
-
215
  } catch (error) {
216
  console.error('DB ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
217
  selector.innerHTML = '<option value="">๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ</option>';
 
193
 
194
  if (!response.ok) throw new Error(data.error || '์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
195
 
196
+ const databases = data.databases || [];
197
+
198
  selector.innerHTML = '';
199
+ if (databases.length === 0) {
200
  const option = document.createElement('option');
201
  option.value = '';
202
  option.textContent = '์„ค์ •๋œ DB ์—†์Œ (์—ฐ๋™ ์„ค์ • ํ•„์š”)';
 
204
  return;
205
  }
206
 
207
+ databases.forEach(db => {
208
  const option = document.createElement('option');
209
  option.value = db.id;
210
+ // ๋ณ„์นญ์ด ์žˆ์œผ๋ฉด ๋ณ„์นญ ํ‘œ์‹œ, ์—†์œผ๋ฉด ์ด๋ฆ„ ํ‘œ์‹œ
211
+ const displayName = db.alias ? `${db.alias} (${db.name})` : db.name;
212
+ option.textContent = displayName;
213
  selector.appendChild(option);
214
  });
215
 
 
 
 
216
  } catch (error) {
217
  console.error('DB ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
218
  selector.innerHTML = '<option value="">๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ</option>';
templates/webnovels.html CHANGED
@@ -691,6 +691,35 @@
691
  </div>
692
  </div>
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  <!-- GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๋ชจ๋‹ฌ -->
695
  <div id="graphRAGVisualizationModal" class="modal">
696
  <div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;">
@@ -741,6 +770,57 @@
741
  </div>
742
  </div>
743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  <!-- vis-network ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ -->
745
  <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
746
 
@@ -971,7 +1051,9 @@
971
  <button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">๐Ÿ“‹ ์š”์•ฝ ๋ณด๊ธฐ</button>
972
  <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">๐Ÿ“– ๋‚ด์šฉ ๋ณด๊ธฐ</button>
973
  <button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">๐Ÿ”— ํšŒ์ฐจ๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„</button>
974
- <button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white;">๐Ÿ“Š ์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™”</button>
 
 
975
  </div>
976
  `;
977
  listContainer.appendChild(fileItem);
@@ -1817,6 +1899,154 @@
1817
  document.getElementById('graphRAGModal').classList.remove('active');
1818
  }
1819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1820
  // GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๊ด€๋ จ ๋ณ€์ˆ˜
1821
  let webnovelGraphData = null;
1822
  let webnovelGraphNetwork = null;
@@ -2364,6 +2594,442 @@
2364
  webnovelAllGraphData = null;
2365
  }
2366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2367
  document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) {
2368
  if (e.target === this) {
2369
  closeGraphRAGVisualizationModal();
 
691
  </div>
692
  </div>
693
 
694
+ <!-- ์‚ฌ๊ฑด๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„ ๋ชจ๋‹ฌ -->
695
+ <div id="graphRAGByEventModal" class="modal">
696
+ <div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh; display: flex; flex-direction: column; padding: 0;">
697
+ <div class="modal-header" style="flex-shrink: 0; padding: 24px 24px 16px 24px; margin-bottom: 0;">
698
+ <div class="modal-title" id="graphRAGByEventModalTitle">์‚ฌ๊ฑด๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„</div>
699
+ <button class="modal-close" onclick="closeGraphRAGByEventModal()">&times;</button>
700
+ </div>
701
+ <div style="display: flex; flex: 1; overflow: hidden;">
702
+ <!-- ์ขŒ์ธก ์‚ฌ์ด๋“œ๋ฐ” (์‚ฌ๊ฑด ๋ชฉ๋ก) -->
703
+ <div id="graphRAGByEventSidebar" style="width: 250px; background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; padding: 16px;">
704
+ <div style="font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border);">
705
+ ์‚ฌ๊ฑด ๋ชฉ๋ก
706
+ </div>
707
+ <div id="graphRAGByEventList" style="display: flex; flex-direction: column; gap: 4px;">
708
+ <div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">
709
+ ์‚ฌ๊ฑด ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
710
+ </div>
711
+ </div>
712
+ </div>
713
+ <!-- ์šฐ์ธก ์ฝ˜ํ…์ธ  ์˜์—ญ -->
714
+ <div id="graphRAGByEventContent" style="flex: 1; overflow-y: auto; padding: 24px;">
715
+ <div style="text-align: center; padding: 24px; color: var(--text-secondary);">
716
+ ์‚ฌ๊ฑด์„ ์„ ํƒํ•˜์—ฌ ์ƒ์„ธ ๋‚ด์šฉ์„ ํ™•์ธํ•˜์„ธ์š”.
717
+ </div>
718
+ </div>
719
+ </div>
720
+ </div>
721
+ </div>
722
+
723
  <!-- GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๋ชจ๋‹ฌ -->
724
  <div id="graphRAGVisualizationModal" class="modal">
725
  <div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;">
 
770
  </div>
771
  </div>
772
 
773
+ <!-- ์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™” (์‚ฌ๊ฑด์ˆœ) ๋ชจ๋‹ฌ -->
774
+ <div id="graphRAGVisualizationByEventModal" class="modal">
775
+ <div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;">
776
+ <div class="modal-header">
777
+ <div class="modal-title" id="graphRAGVisualizationByEventModalTitle">์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™” (์‚ฌ๊ฑด์ˆœ)</div>
778
+ <button class="modal-close" onclick="closeGraphRAGVisualizationByEventModal()">&times;</button>
779
+ </div>
780
+ <div style="padding: 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
781
+ <div style="position: relative;">
782
+ <button id="eventFilterToggleByEvent" onclick="toggleWebnovelEventFilterByEvent()" style="padding: 8px 16px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; color: var(--text-primary); font-weight: 500;">
783
+ <span>์‚ฌ๊ฑด ํ•„ํ„ฐ</span>
784
+ <span id="eventFilterToggleIconByEvent" style="font-size: 12px; transition: transform 0.2s;">โ–ผ</span>
785
+ </button>
786
+ <div id="eventFilterDropdownByEvent" style="display: none; position: absolute; top: 100%; left: 0; margin-top: 4px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 250px; max-width: 400px; max-height: 400px; overflow-y: auto;">
787
+ <div style="padding: 12px; border-bottom: 1px solid var(--border);">
788
+ <label style="font-size: 13px; cursor: pointer; padding: 6px 8px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">
789
+ <input type="checkbox" id="eventFilterAllByEvent" onchange="handleWebnovelEventFilterAllByEvent()" style="margin-right: 8px; cursor: pointer;">
790
+ <span>์ „์ฒด ์„ ํƒ</span>
791
+ </label>
792
+ </div>
793
+ <div id="eventFilterListByEvent" style="padding: 8px;">
794
+ <!-- ์‚ฌ๊ฑด ์ฒดํฌ๋ฐ•์Šค๊ฐ€ ์—ฌ๊ธฐ์— ๋™์ ์œผ๋กœ ์ถ”๊ฐ€๋จ -->
795
+ </div>
796
+ </div>
797
+ </div>
798
+ <div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
799
+ <label style="font-size: 13px;">
800
+ <input type="checkbox" id="showCharactersByEvent" checked onchange="updateGraphVisualizationByEvent()" style="margin-right: 4px;">
801
+ ์ธ๋ฌผ
802
+ </label>
803
+ <label style="font-size: 13px; margin-left: 8px;">
804
+ <input type="checkbox" id="showLocationsByEvent" checked onchange="updateGraphVisualizationByEvent()" style="margin-right: 4px;">
805
+ ์žฅ์†Œ
806
+ </label>
807
+ <label style="font-size: 13px; margin-left: 8px;">
808
+ <input type="checkbox" id="showEventsByEvent" checked onchange="updateGraphVisualizationByEvent()" style="margin-right: 4px;">
809
+ ์‚ฌ๊ฑด
810
+ </label>
811
+ </div>
812
+ <button onclick="resetGraphViewByEvent()" style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; margin-left: auto;">
813
+ ๋ทฐ ๋ฆฌ์…‹
814
+ </button>
815
+ </div>
816
+ <div id="graphRAGVisualizationByEventContent" style="height: calc(90vh - 120px); position: relative; background: var(--bg-primary);">
817
+ <div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
818
+ ์‚ฌ๊ฑด๊ณผ ๋…ธ๋“œ ํƒ€์ž…์„ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.
819
+ </div>
820
+ </div>
821
+ </div>
822
+ </div>
823
+
824
  <!-- vis-network ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ -->
825
  <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
826
 
 
1051
  <button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">๐Ÿ“‹ ์š”์•ฝ ๋ณด๊ธฐ</button>
1052
  <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">๐Ÿ“– ๋‚ด์šฉ ๋ณด๊ธฐ</button>
1053
  <button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">๐Ÿ”— ํšŒ์ฐจ๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„</button>
1054
+ <button class="webnovel-item-btn" onclick="viewGraphRAGByEvent(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #e91e63; color: white; margin-right: 8px;">๐Ÿ“… ์‚ฌ๊ฑด๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„</button>
1055
+ <button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white; margin-right: 8px;">๐Ÿ“Š ์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™”</button>
1056
+ <button class="webnovel-item-btn" onclick="viewGraphRAGVisualizationByEvent(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #ff9800; color: white;">๐Ÿ“Š ์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™” (์‚ฌ๊ฑด์ˆœ)</button>
1057
  </div>
1058
  `;
1059
  listContainer.appendChild(fileItem);
 
1899
  document.getElementById('graphRAGModal').classList.remove('active');
1900
  }
1901
 
1902
+ async function viewGraphRAGByEvent(fileId, fileName) {
1903
+ const modal = document.getElementById('graphRAGByEventModal');
1904
+ const title = document.getElementById('graphRAGByEventModalTitle');
1905
+ const content = document.getElementById('graphRAGByEventContent');
1906
+ const sidebar = document.getElementById('graphRAGByEventList');
1907
+
1908
+ title.textContent = `์‚ฌ๊ฑด๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„ - ${fileName}`;
1909
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">์‚ฌ๊ฑด์„ ์„ ํƒํ•˜์—ฌ ์ƒ์„ธ ๋‚ด์šฉ์„ ํ™•์ธํ•˜์„ธ์š”.</div>';
1910
+ sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">์‚ฌ๊ฑด ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
1911
+ modal.classList.add('active');
1912
+
1913
+ try {
1914
+ const response = await fetch(`/api/files/${fileId}/graph`, {
1915
+ credentials: 'include'
1916
+ });
1917
+ if (!response.ok) throw new Error('GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
1918
+
1919
+ const data = await response.json();
1920
+
1921
+ // ๋ชจ๋“  ์‚ฌ๊ฑด ์ˆ˜์ง‘ (ํƒ€์ž„๋ผ์ธ ์ˆœ์„œ๋กœ ์ •๋ ฌ)
1922
+ const allEvents = [];
1923
+ const episodes = sortEpisodesByNumber(data.episodes || []);
1924
+
1925
+ episodes.forEach(episode => {
1926
+ const events = data.events_by_episode?.[episode] || [];
1927
+ events.forEach(event => {
1928
+ allEvents.push({
1929
+ ...event,
1930
+ episode: episode
1931
+ });
1932
+ });
1933
+ });
1934
+
1935
+ // ์‚ฌ๊ฑด์„ ํƒ€์ž„๋ผ์ธ ์ˆœ์„œ๋กœ ์ •๋ ฌ (ํšŒ์ฐจ ์ˆœ์„œ + ์‚ฌ๊ฑด ์ˆœ์„œ)
1936
+ allEvents.sort((a, b) => {
1937
+ const episodeA = episodes.indexOf(a.episode);
1938
+ const episodeB = episodes.indexOf(b.episode);
1939
+ if (episodeA !== episodeB) {
1940
+ return episodeA - episodeB;
1941
+ }
1942
+ // ๊ฐ™์€ ํšŒ์ฐจ ๋‚ด์—์„œ๋Š” ์‚ฌ๊ฑด ์ด๋ฆ„์œผ๋กœ ์ •๋ ฌ
1943
+ return (a.event_name || '').localeCompare(b.event_name || '');
1944
+ });
1945
+
1946
+ // ์‚ฌ์ด๋“œ๋ฐ”์— ์‚ฌ๊ฑด ๋ชฉ๋ก ํ‘œ์‹œ
1947
+ sidebar.innerHTML = '';
1948
+ if (allEvents.length === 0) {
1949
+ sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">์‚ฌ๊ฑด์ด ์—†์Šต๋‹ˆ๋‹ค.</div>';
1950
+ } else {
1951
+ allEvents.forEach((event, index) => {
1952
+ const eventId = `event-${index}`;
1953
+ const item = document.createElement('div');
1954
+ item.className = 'episode-sidebar-item';
1955
+ item.style.cssText = 'padding: 10px; cursor: pointer; border-radius: 6px; margin-bottom: 4px; transition: background 0.2s;';
1956
+ item.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; };
1957
+ item.onmouseout = function() {
1958
+ if (!this.classList.contains('active')) {
1959
+ this.style.background = 'transparent';
1960
+ }
1961
+ };
1962
+ item.textContent = event.event_name || `์‚ฌ๊ฑด ${index + 1}`;
1963
+ item.onclick = () => showEventDetails(event, eventId, allEvents);
1964
+ if (index === 0) {
1965
+ item.classList.add('active');
1966
+ item.style.background = 'var(--bg-tertiary)';
1967
+ }
1968
+ sidebar.appendChild(item);
1969
+ });
1970
+
1971
+ // ์ฒซ ๋ฒˆ์งธ ์‚ฌ๊ฑด ํ‘œ์‹œ
1972
+ if (allEvents.length > 0) {
1973
+ showEventDetails(allEvents[0], `event-0`, allEvents);
1974
+ }
1975
+ }
1976
+ } catch (error) {
1977
+ console.error('์‚ฌ๊ฑด๋ณ„ GraphRAG ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
1978
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335;">์‚ฌ๊ฑด๋ณ„ GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
1979
+ }
1980
+ }
1981
+
1982
+ function showEventDetails(event, eventId, allEvents) {
1983
+ const content = document.getElementById('graphRAGByEventContent');
1984
+ const sidebarItems = document.querySelectorAll('#graphRAGByEventList .episode-sidebar-item');
1985
+
1986
+ // ์‚ฌ์ด๋“œ๋ฐ” ํ™œ์„ฑํ™” ์ƒํƒœ ์—…๋ฐ์ดํŠธ
1987
+ sidebarItems.forEach((item, index) => {
1988
+ if (item.textContent === (event.event_name || `์‚ฌ๊ฑด ${index + 1}`)) {
1989
+ item.classList.add('active');
1990
+ item.style.background = 'var(--bg-tertiary)';
1991
+ } else {
1992
+ item.classList.remove('active');
1993
+ item.style.background = 'transparent';
1994
+ }
1995
+ });
1996
+
1997
+ let contentHtml = '';
1998
+ contentHtml += `<div style="margin-bottom: 32px; padding: 20px; background: var(--bg-secondary); border-radius: 8px; border-left: 4px solid var(--accent);">`;
1999
+ contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 12px; color: var(--accent);">${escapeHtml(event.event_name || '์‚ฌ๊ฑด')}</h3>`;
2000
+
2001
+ if (event.episode) {
2002
+ contentHtml += `<div style="font-size: 14px; color: var(--text-secondary); margin-bottom: 16px;"><strong>ํšŒ์ฐจ:</strong> ${escapeHtml(event.episode)}</div>`;
2003
+ }
2004
+
2005
+ if (event.description) {
2006
+ contentHtml += `<div style="margin-bottom: 20px; padding: 16px; background: var(--bg-primary); border-radius: 6px;">`;
2007
+ contentHtml += `<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์„ค๋ช…</h4>`;
2008
+ contentHtml += `<div style="font-size: 14px; color: var(--text-primary); line-height: 1.6;">${escapeHtml(event.description)}</div>`;
2009
+ contentHtml += `</div>`;
2010
+ }
2011
+
2012
+ if (event.participants && event.participants.length > 0) {
2013
+ contentHtml += `<div style="margin-bottom: 20px;">`;
2014
+ contentHtml += `<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">๊ด€๋ จ ์ธ๋ฌผ</h4>`;
2015
+ contentHtml += `<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;
2016
+ event.participants.forEach(participant => {
2017
+ contentHtml += `<span style="padding: 6px 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border); font-size: 13px; color: var(--accent); font-weight: 500;">${escapeHtml(participant)}</span>`;
2018
+ });
2019
+ contentHtml += `</div>`;
2020
+ contentHtml += `</div>`;
2021
+ }
2022
+
2023
+ if (event.location) {
2024
+ contentHtml += `<div style="margin-bottom: 20px;">`;
2025
+ contentHtml += `<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์žฅ์†Œ</h4>`;
2026
+ contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">`;
2027
+ contentHtml += `<span style="font-size: 14px; color: var(--text-primary); font-weight: 500;">${escapeHtml(event.location)}</span>`;
2028
+ contentHtml += `</div>`;
2029
+ contentHtml += `</div>`;
2030
+ }
2031
+
2032
+ if (event.significance) {
2033
+ contentHtml += `<div style="margin-bottom: 20px;">`;
2034
+ contentHtml += `<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์ค‘์š”๋„</h4>`;
2035
+ contentHtml += `<div style="padding: 12px; background: #e8f5e9; border-radius: 6px; border: 1px solid #c8e6c9;">`;
2036
+ contentHtml += `<span style="font-size: 14px; color: #137333; font-weight: 500;">${escapeHtml(event.significance)}</span>`;
2037
+ contentHtml += `</div>`;
2038
+ contentHtml += `</div>`;
2039
+ }
2040
+
2041
+ contentHtml += `</div>`;
2042
+
2043
+ content.innerHTML = contentHtml;
2044
+ }
2045
+
2046
+ function closeGraphRAGByEventModal() {
2047
+ document.getElementById('graphRAGByEventModal').classList.remove('active');
2048
+ }
2049
+
2050
  // GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๊ด€๋ จ ๋ณ€์ˆ˜
2051
  let webnovelGraphData = null;
2052
  let webnovelGraphNetwork = null;
 
2594
  webnovelAllGraphData = null;
2595
  }
2596
 
2597
+ // ์‚ฌ๊ฑด์ˆœ ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๊ด€๋ จ ๋ณ€์ˆ˜
2598
+ let webnovelGraphDataByEvent = null;
2599
+ let webnovelGraphNetworkByEvent = null;
2600
+ let webnovelAllGraphDataByEvent = null;
2601
+
2602
+ async function viewGraphRAGVisualizationByEvent(fileId, fileName) {
2603
+ const modal = document.getElementById('graphRAGVisualizationByEventModal');
2604
+ const title = document.getElementById('graphRAGVisualizationByEventModalTitle');
2605
+ const content = document.getElementById('graphRAGVisualizationByEventContent');
2606
+
2607
+ title.textContent = `์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™” (์‚ฌ๊ฑด์ˆœ) - ${fileName}`;
2608
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">์‚ฌ๊ฑด๊ณผ ๋…ธ๋“œ ํƒ€์ž…์„ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
2609
+ modal.classList.add('active');
2610
+
2611
+ // ๊ธฐ์กด ๋„คํŠธ์›Œํฌ ์ œ๊ฑฐ
2612
+ if (webnovelGraphNetworkByEvent) {
2613
+ webnovelGraphNetworkByEvent.destroy();
2614
+ webnovelGraphNetworkByEvent = null;
2615
+ }
2616
+
2617
+ // ์ฒดํฌ๋ฐ•์Šค ์ดˆ๊ธฐํ™”
2618
+ document.getElementById('showCharactersByEvent').checked = true;
2619
+ document.getElementById('showLocationsByEvent').checked = true;
2620
+ document.getElementById('showEventsByEvent').checked = true;
2621
+
2622
+ try {
2623
+ const response = await fetch(`/api/files/${fileId}/graph`, {
2624
+ credentials: 'include'
2625
+ });
2626
+ if (!response.ok) throw new Error('GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
2627
+
2628
+ const data = await response.json();
2629
+ webnovelAllGraphDataByEvent = data;
2630
+
2631
+ // ์‚ฌ๊ฑด ํ•„ํ„ฐ ์ฒดํฌ๋ฐ•์Šค ์ƒ์„ฑ (ํƒ€์ž„๋ผ์ธ ์ˆœ์„œ๋กœ ์ •๋ ฌ)
2632
+ const eventFilterList = document.getElementById('eventFilterListByEvent');
2633
+ eventFilterList.innerHTML = '';
2634
+ const eventFilterAll = document.getElementById('eventFilterAllByEvent');
2635
+ eventFilterAll.checked = false;
2636
+
2637
+ // ๋ชจ๋“  ์‚ฌ๊ฑด ์ˆ˜์ง‘
2638
+ const allEvents = [];
2639
+ const episodes = sortEpisodesByNumber(data.episodes || []);
2640
+
2641
+ episodes.forEach(episode => {
2642
+ const events = data.events_by_episode?.[episode] || [];
2643
+ events.forEach(event => {
2644
+ allEvents.push({
2645
+ ...event,
2646
+ episode: episode
2647
+ });
2648
+ });
2649
+ });
2650
+
2651
+ // ์‚ฌ๊ฑด์„ ํƒ€์ž„๋ผ์ธ ์ˆœ์„œ๋กœ ์ •๋ ฌ
2652
+ allEvents.sort((a, b) => {
2653
+ const episodeA = episodes.indexOf(a.episode);
2654
+ const episodeB = episodes.indexOf(b.episode);
2655
+ if (episodeA !== episodeB) {
2656
+ return episodeA - episodeB;
2657
+ }
2658
+ return (a.event_name || '').localeCompare(b.event_name || '');
2659
+ });
2660
+
2661
+ if (allEvents.length > 0) {
2662
+ allEvents.forEach((event, index) => {
2663
+ const label = document.createElement('label');
2664
+ label.style.cssText = 'font-size: 13px; cursor: pointer; padding: 6px 12px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;';
2665
+ label.onmouseover = function() { this.style.background = 'var(--bg-secondary)'; };
2666
+ label.onmouseout = function() { this.style.background = 'transparent'; };
2667
+
2668
+ const checkbox = document.createElement('input');
2669
+ checkbox.type = 'checkbox';
2670
+ checkbox.value = JSON.stringify({ event_name: event.event_name, episode: event.episode });
2671
+ checkbox.id = `eventFilterByEvent_${index}`;
2672
+ checkbox.onchange = handleWebnovelIndividualEventChangeByEvent;
2673
+ checkbox.style.marginRight = '8px';
2674
+ checkbox.style.cursor = 'pointer';
2675
+
2676
+ const span = document.createElement('span');
2677
+ span.textContent = event.event_name || `์‚ฌ๊ฑด ${index + 1}`;
2678
+ span.style.flex = '1';
2679
+
2680
+ label.appendChild(checkbox);
2681
+ label.appendChild(span);
2682
+ eventFilterList.appendChild(label);
2683
+ });
2684
+ }
2685
+
2686
+ // ๋ฒ„ํŠผ ํ…์ŠคํŠธ ์ดˆ๊ธฐํ™”
2687
+ updateWebnovelEventFilterButtonTextByEvent();
2688
+
2689
+ // ์ดˆ๊ธฐ์—๋Š” ๊ทธ๋ž˜ํ”„๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ
2690
+ } catch (error) {
2691
+ console.error('GraphRAG ๊ทธ๋ž˜ํ”„ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
2692
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๊ทธ๋ž˜ํ”„๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
2693
+ }
2694
+ }
2695
+
2696
+ function createWebnovelGraphVisualizationByEvent(data, eventFilter = []) {
2697
+ const content = document.getElementById('graphRAGVisualizationByEventContent');
2698
+
2699
+ // ํ•„ํ„ฐ๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜๊ฑฐ๋‚˜ ๋นˆ ๋ฐฐ์—ด์ธ ๊ฒฝ์šฐ ๋นˆ ํ™”๋ฉด ํ‘œ์‹œ
2700
+ if (!eventFilter || eventFilter.length === 0) {
2701
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">์‚ฌ๊ฑด์„ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
2702
+ return;
2703
+ }
2704
+
2705
+ const showCharacters = document.getElementById('showCharactersByEvent').checked;
2706
+ const showLocations = document.getElementById('showLocationsByEvent').checked;
2707
+ const showEvents = document.getElementById('showEventsByEvent').checked;
2708
+
2709
+ // ์ฒดํฌ๋ฐ•์Šค๊ฐ€ ๋ชจ๋‘ ํ•ด์ œ๋œ ๊ฒฝ์šฐ ๋นˆ ํ™”๋ฉด ํ‘œ์‹œ
2710
+ if (!showCharacters && !showLocations && !showEvents) {
2711
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๋…ธ๋“œ ํƒ€์ž…(์ธ๋ฌผ, ์žฅ์†Œ, ์‚ฌ๊ฑด)์„ ํ•˜๋‚˜ ์ด์ƒ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
2712
+ return;
2713
+ }
2714
+
2715
+ content.innerHTML = ''; // ๊ธฐ์กด ๋‚ด์šฉ ์ œ๊ฑฐ
2716
+
2717
+ // ๋…ธ๋“œ์™€ ์—ฃ์ง€ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ
2718
+ const nodes = new vis.DataSet([]);
2719
+ const edges = new vis.DataSet([]);
2720
+
2721
+ const nodeMap = new Map(); // ๋…ธ๋“œ ID ๋งคํ•‘
2722
+ const nodeEventsMap = new Map(); // ๋…ธ๋“œ๋ณ„ ๊ด€๋ จ ์‚ฌ๊ฑด ์ถ”์  (nodeId -> Set of event names)
2723
+ let nodeIdCounter = 1;
2724
+
2725
+ // ์„ ํƒ๋œ ์‚ฌ๊ฑด๋“ค์˜ ํšŒ์ฐจ ์ˆ˜์ง‘
2726
+ const selectedEpisodes = new Set();
2727
+ eventFilter.forEach(eventData => {
2728
+ const event = typeof eventData === 'string' ? JSON.parse(eventData) : eventData;
2729
+ if (event.episode) {
2730
+ selectedEpisodes.add(event.episode);
2731
+ }
2732
+ });
2733
+
2734
+ // ์—”ํ‹ฐํ‹ฐ ์ถ”๊ฐ€ (์ธ๋ฌผ, ์žฅ์†Œ) - ์„ ํƒ๋œ ์‚ฌ๊ฑด์ด ์žˆ๋Š” ํšŒ์ฐจ๋งŒ
2735
+ selectedEpisodes.forEach(episode => {
2736
+ const entities = data.entities_by_episode?.[episode] || {};
2737
+
2738
+ // ์ธ๋ฌผ ์ถ”๊ฐ€
2739
+ if (showCharacters && entities.characters) {
2740
+ entities.characters.forEach(char => {
2741
+ const nodeId = `char_${char.entity_name}`;
2742
+ if (!nodeMap.has(nodeId)) {
2743
+ const id = nodeIdCounter++;
2744
+ nodeMap.set(nodeId, id);
2745
+ nodeEventsMap.set(id, new Set()); // ์‚ฌ๊ฑด ์ถ”์ ์„ ์œ„ํ•œ Set ์ดˆ๊ธฐํ™”
2746
+ nodes.add({
2747
+ id: id,
2748
+ label: char.entity_name,
2749
+ title: `์ธ๋ฌผ: ${char.entity_name}\n์—ญํ• : ${char.role || '์—†์Œ'}\n์„ค๋ช…: ${char.description || '์—†์Œ'}`,
2750
+ color: {
2751
+ background: '#4285f4',
2752
+ border: '#1967d2',
2753
+ highlight: { background: '#1a73e8', border: '#1557b0' }
2754
+ },
2755
+ shape: 'ellipse',
2756
+ font: { size: 14, face: 'Inter' },
2757
+ size: 20
2758
+ });
2759
+ }
2760
+ });
2761
+ }
2762
+
2763
+ // ์žฅ์†Œ ์ถ”๊ฐ€
2764
+ if (showLocations && entities.locations) {
2765
+ entities.locations.forEach(loc => {
2766
+ const nodeId = `loc_${loc.entity_name}`;
2767
+ if (!nodeMap.has(nodeId)) {
2768
+ const id = nodeIdCounter++;
2769
+ nodeMap.set(nodeId, id);
2770
+ nodeEventsMap.set(id, new Set()); // ์‚ฌ๊ฑด ์ถ”์ ์„ ์œ„ํ•œ Set ์ดˆ๊ธฐํ™”
2771
+ nodes.add({
2772
+ id: id,
2773
+ label: loc.entity_name,
2774
+ title: `์žฅ์†Œ: ${loc.entity_name}\n์œ ํ˜•: ${loc.category || '์—†์Œ'}\n์„ค๋ช…: ${loc.description || '์—†์Œ'}`,
2775
+ color: {
2776
+ background: '#34a853',
2777
+ border: '#137333',
2778
+ highlight: { background: '#2e7d32', border: '#1b5e20' }
2779
+ },
2780
+ shape: 'box',
2781
+ font: { size: 14, face: 'Inter' },
2782
+ size: 20
2783
+ });
2784
+ }
2785
+ });
2786
+ }
2787
+ });
2788
+
2789
+ // ์‚ฌ๊ฑด ๋…ธ๋“œ ์ถ”๊ฐ€ - ์„ ํƒ๋œ ์‚ฌ๊ฑด๋งŒ
2790
+ if (showEvents) {
2791
+ eventFilter.forEach(eventData => {
2792
+ const event = typeof eventData === 'string' ? JSON.parse(eventData) : eventData;
2793
+ const eventName = event.event_name;
2794
+ const episode = event.episode;
2795
+
2796
+ if (eventName) {
2797
+ const nodeId = `event_${eventName}_${episode}`;
2798
+ if (!nodeMap.has(nodeId)) {
2799
+ const id = nodeIdCounter++;
2800
+ nodeMap.set(nodeId, id);
2801
+ nodeEventsMap.set(id, new Set()); // ์‚ฌ๊ฑด ์ถ”์ ์„ ์œ„ํ•œ Set ์ดˆ๊ธฐํ™”
2802
+
2803
+ // ์‚ฌ๊ฑด ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
2804
+ const events = data.events_by_episode?.[episode] || [];
2805
+ const eventInfo = events.find(e => e.event_name === eventName) || {};
2806
+
2807
+ nodes.add({
2808
+ id: id,
2809
+ label: eventName,
2810
+ title: `์‚ฌ๊ฑด: ${eventName}\nํšŒ์ฐจ: ${episode}\n์„ค๋ช…: ${eventInfo.description || '์—†์Œ'}`,
2811
+ color: {
2812
+ background: '#ff9800',
2813
+ border: '#f57c00',
2814
+ highlight: { background: '#fb8c00', border: '#e65100' }
2815
+ },
2816
+ shape: 'diamond',
2817
+ font: { size: 14, face: 'Inter' },
2818
+ size: 20
2819
+ });
2820
+ }
2821
+ }
2822
+ });
2823
+ }
2824
+
2825
+ // ๊ด€๊ณ„ ์ถ”๊ฐ€ - ์„ ํƒ๋œ ์‚ฌ๊ฑด๊ณผ ๊ด€๋ จ๋œ ๊ด€๊ณ„๋งŒ
2826
+ selectedEpisodes.forEach(episode => {
2827
+ const relationships = data.relationships_by_episode?.[episode] || [];
2828
+ relationships.forEach(rel => {
2829
+ // ์„ ํƒ๋œ ์‚ฌ๊ฑด ์ค‘ ํ•˜๋‚˜์™€ ๊ด€๋ จ๋œ ๊ด€๊ณ„๋งŒ ์ถ”๊ฐ€
2830
+ const relatedEvent = eventFilter.find(eventData => {
2831
+ const event = typeof eventData === 'string' ? JSON.parse(eventData) : eventData;
2832
+ return rel.event === event.event_name;
2833
+ });
2834
+
2835
+ if (relatedEvent) {
2836
+ const event = typeof relatedEvent === 'string' ? JSON.parse(relatedEvent) : relatedEvent;
2837
+ const eventName = event.event_name;
2838
+
2839
+ // source์™€ target์ด ์ธ๋ฌผ์ธ์ง€ ์žฅ์†Œ์ธ์ง€ ํ™•์ธ
2840
+ let sourceNodeId = `char_${rel.source}`;
2841
+ let targetNodeId = `char_${rel.target}`;
2842
+
2843
+ // ์„ ํƒ๋œ ํšŒ์ฐจ์˜ ์—”ํ‹ฐํ‹ฐ๋ฅผ ํ™•์ธํ•˜์—ฌ ๋…ธ๋“œ ID ๊ฒฐ์ •
2844
+ const entities = data.entities_by_episode?.[episode] || {};
2845
+ const characters = entities.characters || [];
2846
+ const locations = entities.locations || [];
2847
+
2848
+ const sourceIsLocation = locations.some(loc => loc.entity_name === rel.source);
2849
+ const targetIsLocation = locations.some(loc => loc.entity_name === rel.target);
2850
+
2851
+ if (sourceIsLocation) {
2852
+ sourceNodeId = `loc_${rel.source}`;
2853
+ }
2854
+ if (targetIsLocation) {
2855
+ targetNodeId = `loc_${rel.target}`;
2856
+ }
2857
+
2858
+ const sourceId = nodeMap.get(sourceNodeId);
2859
+ const targetId = nodeMap.get(targetNodeId);
2860
+
2861
+ if (sourceId && targetId) {
2862
+ // ๋…ธ๋“œ์— ์‚ฌ๊ฑด ์ •๋ณด ์ถ”๊ฐ€
2863
+ if (nodeEventsMap.has(sourceId)) {
2864
+ nodeEventsMap.get(sourceId).add(eventName);
2865
+ }
2866
+ if (nodeEventsMap.has(targetId)) {
2867
+ nodeEventsMap.get(targetId).add(eventName);
2868
+ }
2869
+
2870
+ edges.add({
2871
+ from: sourceId,
2872
+ to: targetId,
2873
+ label: rel.relationship_type || '',
2874
+ title: `${rel.source} โ†’ ${rel.target}\n๊ด€๊ณ„: ${rel.relationship_type || '์—†์Œ'}\n์„ค๋ช…: ${rel.description || '์—†์Œ'}`,
2875
+ arrows: 'to',
2876
+ color: { color: '#5f6368', highlight: '#1a73e8' },
2877
+ font: { size: 12, align: 'middle' },
2878
+ smooth: { type: 'continuous' }
2879
+ });
2880
+
2881
+ // ์‚ฌ๊ฑด ๋…ธ๋“œ์™€ ์ธ๋ฌผ/์žฅ์†Œ ๋…ธ๋“œ ์—ฐ๊ฒฐ
2882
+ if (showEvents) {
2883
+ const eventNodeId = `event_${eventName}_${episode}`;
2884
+ const eventId = nodeMap.get(eventNodeId);
2885
+
2886
+ if (eventId) {
2887
+ // ์‚ฌ๊ฑด -> source ์—ฐ๊ฒฐ
2888
+ edges.add({
2889
+ from: eventId,
2890
+ to: sourceId,
2891
+ label: '',
2892
+ title: `์‚ฌ๊ฑด: ${eventName}\nโ†’ ${rel.source}`,
2893
+ arrows: 'to',
2894
+ color: { color: '#ff9800', highlight: '#fb8c00' },
2895
+ font: { size: 11, align: 'middle' },
2896
+ smooth: { type: 'continuous' },
2897
+ dashes: true
2898
+ });
2899
+
2900
+ // ์‚ฌ๊ฑด -> target ์—ฐ๊ฒฐ
2901
+ edges.add({
2902
+ from: eventId,
2903
+ to: targetId,
2904
+ label: '',
2905
+ title: `์‚ฌ๊ฑด: ${eventName}\nโ†’ ${rel.target}`,
2906
+ arrows: 'to',
2907
+ color: { color: '#ff9800', highlight: '#fb8c00' },
2908
+ font: { size: 11, align: 'middle' },
2909
+ smooth: { type: 'continuous' },
2910
+ dashes: true
2911
+ });
2912
+ }
2913
+ }
2914
+ }
2915
+ }
2916
+ });
2917
+ });
2918
+
2919
+ // ๋…ธ๋“œ์˜ title์— ์‚ฌ๊ฑด ์ •๋ณด ์ถ”๊ฐ€
2920
+ nodes.forEach(node => {
2921
+ const events = nodeEventsMap.get(node.id);
2922
+ if (events && events.size > 0) {
2923
+ const eventsList = Array.from(events).join(', ');
2924
+ const currentTitle = node.title || '';
2925
+ node.title = currentTitle + `\n๊ด€๋ จ ์‚ฌ๊ฑด: ${eventsList}`;
2926
+ nodes.update(node);
2927
+ }
2928
+ });
2929
+
2930
+ if (nodes.length === 0) {
2931
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">์„ ํƒํ•œ ์‚ฌ๊ฑด์— ๊ด€๋ จ๋œ ๋…ธ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>';
2932
+ return;
2933
+ }
2934
+
2935
+ // ๋„คํŠธ์›Œํฌ ์ƒ์„ฑ
2936
+ const container = document.createElement('div');
2937
+ container.id = 'graphRAGVisualizationByEventNetwork';
2938
+ container.style.width = '100%';
2939
+ container.style.height = '100%';
2940
+ content.appendChild(container);
2941
+
2942
+ const graphData = { nodes: nodes, edges: edges };
2943
+ const options = {
2944
+ nodes: {
2945
+ borderWidth: 2,
2946
+ shadow: true
2947
+ },
2948
+ edges: {
2949
+ width: 2,
2950
+ shadow: true,
2951
+ smooth: { type: 'continuous' }
2952
+ },
2953
+ physics: {
2954
+ enabled: true,
2955
+ stabilization: { iterations: 200 }
2956
+ },
2957
+ interaction: {
2958
+ hover: true,
2959
+ tooltipDelay: 100,
2960
+ zoomView: true,
2961
+ dragView: true
2962
+ }
2963
+ };
2964
+
2965
+ webnovelGraphNetworkByEvent = new vis.Network(container, graphData, options);
2966
+ webnovelGraphDataByEvent = graphData;
2967
+ }
2968
+
2969
+ function updateGraphVisualizationByEvent() {
2970
+ if (!webnovelAllGraphDataByEvent) return;
2971
+
2972
+ const eventFilter = getSelectedEventsByEvent();
2973
+ createWebnovelGraphVisualizationByEvent(webnovelAllGraphDataByEvent, eventFilter);
2974
+ }
2975
+
2976
+ function getSelectedEventsByEvent() {
2977
+ const checkboxes = document.querySelectorAll('#eventFilterListByEvent input[type="checkbox"]:checked');
2978
+ return Array.from(checkboxes).map(cb => cb.value);
2979
+ }
2980
+
2981
+ function toggleWebnovelEventFilterByEvent() {
2982
+ const dropdown = document.getElementById('eventFilterDropdownByEvent');
2983
+ const icon = document.getElementById('eventFilterToggleIconByEvent');
2984
+ const isVisible = dropdown.style.display !== 'none';
2985
+ dropdown.style.display = isVisible ? 'none' : 'block';
2986
+ icon.style.transform = isVisible ? 'rotate(0deg)' : 'rotate(180deg)';
2987
+ }
2988
+
2989
+ function handleWebnovelEventFilterAllByEvent() {
2990
+ const allCheckbox = document.getElementById('eventFilterAllByEvent');
2991
+ const checkboxes = document.querySelectorAll('#eventFilterListByEvent input[type="checkbox"]');
2992
+ checkboxes.forEach(cb => {
2993
+ cb.checked = allCheckbox.checked;
2994
+ });
2995
+ updateWebnovelEventFilterButtonTextByEvent();
2996
+ updateGraphVisualizationByEvent();
2997
+ }
2998
+
2999
+ function handleWebnovelIndividualEventChangeByEvent() {
3000
+ const allCheckbox = document.getElementById('eventFilterAllByEvent');
3001
+ const checkboxes = document.querySelectorAll('#eventFilterListByEvent input[type="checkbox"]');
3002
+ const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
3003
+ allCheckbox.checked = checkedCount === checkboxes.length;
3004
+ updateWebnovelEventFilterButtonTextByEvent();
3005
+ updateGraphVisualizationByEvent();
3006
+ }
3007
+
3008
+ function updateWebnovelEventFilterButtonTextByEvent() {
3009
+ const button = document.getElementById('eventFilterToggleByEvent');
3010
+ const selectedCount = getSelectedEventsByEvent().length;
3011
+ const buttonText = button.querySelector('span:first-child');
3012
+ if (selectedCount === 0) {
3013
+ buttonText.textContent = '์‚ฌ๊ฑด ํ•„ํ„ฐ';
3014
+ } else {
3015
+ buttonText.textContent = `์‚ฌ๊ฑด ํ•„ํ„ฐ (${selectedCount}๊ฐœ ์„ ํƒ๋จ)`;
3016
+ }
3017
+ }
3018
+
3019
+ function resetGraphViewByEvent() {
3020
+ if (webnovelGraphNetworkByEvent) {
3021
+ webnovelGraphNetworkByEvent.fit();
3022
+ }
3023
+ }
3024
+
3025
+ function closeGraphRAGVisualizationByEventModal() {
3026
+ document.getElementById('graphRAGVisualizationByEventModal').classList.remove('active');
3027
+ if (webnovelGraphNetworkByEvent) {
3028
+ webnovelGraphNetworkByEvent.destroy();
3029
+ webnovelGraphNetworkByEvent = null;
3030
+ }
3031
+ }
3032
+
3033
  document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) {
3034
  if (e.target === this) {
3035
  closeGraphRAGVisualizationModal();
templates/webtoon_personal_management.html CHANGED
@@ -151,6 +151,124 @@
151
  margin-bottom: 20px;
152
  }
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  /* Gantt ์Šคํƒ€์ผ */
155
  .gantt-wrapper {
156
  display: flex;
@@ -342,6 +460,70 @@
342
  ๐Ÿ“… ์ด๋ฒˆ ์ฃผ: {{ start_of_week }} ~ {{ end_of_week }}
343
  </div>
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  <!-- ๊ณต์ง€์‚ฌํ•ญ ์„น์…˜ -->
346
  <div id="noticeContainer" class="notice-container">
347
  <!-- ๊ณต์ง€์‚ฌํ•ญ์ด ์—ฌ๊ธฐ์— ๋กœ๋“œ๋ฉ๋‹ˆ๋‹ค -->
@@ -389,37 +571,6 @@
389
  </div>
390
  {% endif %}
391
 
392
- <!-- Notion ์ผ์ •ํ‘œ ์„น์…˜ (Gantt ์ „์šฉ) -->
393
- <div class="notion-schedule-card">
394
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
395
- <h2 style="font-size: 20px; font-weight: 700; margin: 0;">๐Ÿ“… Notion ์ผ์ •ํ‘œ (์ง€๋‚œ ์ฃผ ~ ์ด๋ฒˆ ์ฃผ)</h2>
396
- <button onclick="loadNotionSchedule()" style="padding: 6px 12px; font-size: 12px; background: var(--accent); color: white; border: none; border-radius: 4px; cursor: pointer;">์ƒˆ๋กœ๊ณ ์นจ</button>
397
- </div>
398
-
399
- <div id="notionLoading" class="loading-spinner" style="display: none;">
400
- <div class="spinner-icon"></div>
401
- ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
402
- </div>
403
-
404
- <div id="notionGanttSection" style="margin-top: 16px; display: none;">
405
- <div class="gantt-wrapper" id="notionGanttWrapper" style="height: auto; min-height: 200px;">
406
- <div class="gantt-side">
407
- <div class="gantt-side-header">์—…๋ฌด๋ช…</div>
408
- <div class="gantt-side-list" id="notionGanttSideList"></div>
409
- </div>
410
- <div class="gantt-right-col">
411
- <div class="gantt-chart-container" id="notionGanttContainer">
412
- <svg id="notionGanttSvg"></svg>
413
- </div>
414
- </div>
415
- </div>
416
- </div>
417
-
418
- <div id="notionEmptyState" style="text-align: center; padding: 40px; color: var(--muted); border: 1px dashed var(--border); border-radius: 8px; display: none;">
419
- ์ผ์ • ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
420
- </div>
421
- </div>
422
-
423
  {% if matched_data %}
424
  {% for project in matched_data %}
425
  <div style="margin-top: 40px;">
@@ -659,13 +810,16 @@
659
 
660
  async function loadNotionSchedule() {
661
  const loading = document.getElementById('notionLoading');
 
662
  const ganttSection = document.getElementById('notionGanttSection');
663
  const emptyState = document.getElementById('notionEmptyState');
 
664
 
665
- loading.style.display = 'flex';
666
- ganttSection.style.display = 'none';
667
- emptyState.style.display = 'none';
668
- document.getElementById('notionGanttSideList').innerHTML = '';
 
669
 
670
  try {
671
  let nicknamesToFetch = [];
@@ -680,11 +834,13 @@
680
 
681
  let allResults = [];
682
  let dateProperty = null;
 
 
683
 
684
  const total = nicknamesToFetch.length;
685
  for (let i = 0; i < total; i++) {
686
  const name = nicknamesToFetch[i];
687
- loading.innerHTML = `<div class="spinner-icon"></div> [${i+1}/${total}] ${name || '์ „์ฒด'} ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...`;
688
 
689
  let url = `/api/admin/webtoon/agent/schedule?t=${new Date().getTime()}&page_size=50`;
690
  if (name) {
@@ -701,12 +857,28 @@
701
  if (!dateProperty && data.date_property) {
702
  dateProperty = data.date_property;
703
  }
 
 
 
 
 
 
704
  } else {
705
  console.error(`Failed to load schedule for ${name}:`, data.error);
 
 
706
  }
707
  }
708
 
709
- loading.innerHTML = `<div class="spinner-icon"></div> ๋ฐ์ดํ„ฐ๋ฅผ ์ •๋ฆฌํ•˜๋Š” ์ค‘...`;
 
 
 
 
 
 
 
 
710
 
711
  if (allResults.length === 0) {
712
  emptyState.style.display = 'block';
@@ -724,20 +896,112 @@
724
  }
725
 
726
  renderNotionGantt(uniqueResults, dateProperty);
727
- ganttSection.style.display = 'block';
728
  } catch (error) {
729
  console.error('Notion schedule load failed:', error);
730
- emptyState.textContent = '๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message;
731
- emptyState.style.display = 'block';
 
 
732
  } finally {
733
- loading.style.display = 'none';
734
- loading.innerHTML = `<div class="spinner-icon"></div> ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...`;
 
 
735
  }
736
  }
737
 
738
  function renderNotionGantt(results, datePropName) {
739
  const tasks = [];
740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  results.forEach((item, idx) => {
742
  const props = item.properties;
743
 
@@ -762,64 +1026,114 @@
762
  let endDate = dateDetail.end || startDate;
763
 
764
  if (startDate) {
765
- // ์‹œ๊ฐ„ ํฌํ•จ๋œ ๊ฒฝ์šฐ ๋‚ ์งœ๋งŒ ์ถ”์ถœ (YYYY-MM-DD)
766
- startDate = startDate.split('T')[0];
767
- endDate = endDate.split('T')[0];
768
 
769
- tasks.push({
770
- id: `notion-task-${idx}`,
771
- name: taskName,
772
- start: startDate,
773
- end: endDate,
774
- progress: 0,
775
- custom_class: 'notion-task',
776
- level: 'item'
777
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  }
779
  });
780
-
781
- if (tasks.length === 0) {
782
- document.getElementById('notionEmptyState').style.display = 'block';
783
- document.getElementById('notionGanttSection').style.display = 'none';
784
- return;
785
- }
786
 
787
- // ์ •๋ ฌ (์ตœ์‹  ์ผ์ž๊ฐ€ ์œ„๋กœ ์˜ค๋„๋ก ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ)
788
- tasks.sort((a, b) => new Date(b.start) - new Date(a.start));
 
 
 
 
 
 
 
 
 
 
789
 
790
- const ganttObj = new Gantt("#notionGanttSvg", tasks, {
791
- header_height: 60,
792
- column_width: 32,
793
- step: 24,
794
- view_mode: 'Day',
795
- bar_height: 25,
796
- bar_corner_radius: 4,
797
- padding: 15,
798
- date_format: 'YYYY-MM-DD',
799
- readonly: true
800
- });
801
 
802
- // ์‚ฌ์ด๋“œ ๋ฆฌ์ŠคํŠธ ๋ฐ ๋ ˆ์ด์•„์›ƒ ์กฐ์ •
803
- const sideList = document.getElementById('notionGanttSideList');
804
- const container = document.getElementById('notionGanttContainer');
805
- const svg = document.getElementById('notionGanttSvg');
806
-
807
- sideList.innerHTML = '';
808
- tasks.forEach(t => {
809
- const div = document.createElement('div');
810
- div.className = 'gantt-side-item';
811
- div.textContent = t.name;
812
- div.title = t.name;
813
- sideList.appendChild(div);
814
- });
815
 
816
- container.onscroll = () => { sideList.scrollTop = container.scrollTop; };
 
 
 
 
 
 
 
 
 
 
817
 
818
- const rowCount = tasks.length;
819
- const calculatedHeight = 60 + (rowCount * 40) + 20;
820
- svg.setAttribute('height', calculatedHeight);
821
- svg.style.height = calculatedHeight + 'px';
822
- document.getElementById('notionGanttWrapper').style.height = (calculatedHeight > 400 ? 400 : calculatedHeight) + 'px';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  }
824
 
825
  function formatValue(val) {
 
151
  margin-bottom: 20px;
152
  }
153
 
154
+ /* Notion Weekly Dashboard Styles */
155
+ .notion-weekly-dashboard {
156
+ display: grid;
157
+ grid-template-columns: repeat(7, 1fr);
158
+ gap: 12px;
159
+ margin-top: 24px;
160
+ overflow-x: auto;
161
+ padding-bottom: 16px;
162
+ }
163
+ .notion-day-column {
164
+ background: #f8f9fa;
165
+ border: 1px solid var(--border);
166
+ border-radius: 10px;
167
+ min-width: 180px;
168
+ display: flex;
169
+ flex-direction: column;
170
+ overflow: hidden;
171
+ }
172
+ .notion-day-header {
173
+ background: #ffffff;
174
+ border-bottom: 1px solid var(--border);
175
+ padding: 12px;
176
+ text-align: center;
177
+ }
178
+ .notion-day-name {
179
+ font-size: 14px;
180
+ font-weight: 700;
181
+ color: var(--accent);
182
+ margin-bottom: 4px;
183
+ }
184
+ .notion-day-date {
185
+ font-size: 12px;
186
+ color: var(--muted);
187
+ }
188
+ .notion-day-tasks {
189
+ flex: 1;
190
+ padding: 8px;
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 8px;
194
+ min-height: 100px;
195
+ }
196
+ .notion-task-card {
197
+ background: #ffffff;
198
+ border: 1px solid #e0e0e0;
199
+ border-radius: 6px;
200
+ padding: 10px;
201
+ font-size: 12px;
202
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
203
+ transition: transform 0.2s;
204
+ }
205
+ .notion-task-card.planned {
206
+ border-left: 4px solid #fbc02d;
207
+ background: #fffdf7;
208
+ }
209
+ .notion-task-card:hover {
210
+ transform: translateY(-2px);
211
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
212
+ }
213
+ .notion-task-badge {
214
+ display: inline-block;
215
+ padding: 1px 4px;
216
+ border-radius: 3px;
217
+ font-size: 10px;
218
+ font-weight: 700;
219
+ margin-right: 4px;
220
+ vertical-align: middle;
221
+ }
222
+ .badge-notion { background: #e8f0fe; color: #1a73e8; }
223
+ .badge-planned { background: #fff8e1; color: #f57f17; }
224
+ .notion-task-title {
225
+ font-weight: 600;
226
+ margin-bottom: 4px;
227
+ color: #3c4043;
228
+ word-break: break-all;
229
+ }
230
+ .notion-task-meta {
231
+ font-size: 11px;
232
+ color: #70757a;
233
+ display: flex;
234
+ justify-content: space-between;
235
+ align-items: center;
236
+ }
237
+ .notion-day-column.today {
238
+ border-color: var(--accent);
239
+ box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
240
+ }
241
+ .notion-day-column.today .notion-day-header {
242
+ background: #e8f0fe;
243
+ }
244
+
245
+ /* ํ†ต๊ณ„ ์š”์•ฝ ๋ฐ” */
246
+ .notion-stats-bar {
247
+ display: grid;
248
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
249
+ gap: 16px;
250
+ margin-bottom: 24px;
251
+ }
252
+ .notion-stat-box {
253
+ background: #ffffff;
254
+ border: 1px solid var(--border);
255
+ border-radius: 8px;
256
+ padding: 16px;
257
+ text-align: center;
258
+ }
259
+ .notion-stat-label {
260
+ font-size: 12px;
261
+ color: var(--muted);
262
+ margin-bottom: 4px;
263
+ text-transform: uppercase;
264
+ font-weight: 600;
265
+ }
266
+ .notion-stat-value {
267
+ font-size: 20px;
268
+ font-weight: 700;
269
+ color: var(--text-primary);
270
+ }
271
+
272
  /* Gantt ์Šคํƒ€์ผ */
273
  .gantt-wrapper {
274
  display: flex;
 
460
  ๐Ÿ“… ์ด๋ฒˆ ์ฃผ: {{ start_of_week }} ~ {{ end_of_week }}
461
  </div>
462
 
463
+ <!-- Notion ์ผ์ •ํ‘œ ์„น์…˜ -->
464
+ <div class="notion-schedule-card">
465
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
466
+ <h2 style="font-size: 20px; font-weight: 700; margin: 0;">๐Ÿ“… ์ฃผ๊ฐ„ ์—…๋ฌด ๋ณด๋“œ</h2>
467
+ <div style="display: flex; gap: 8px;">
468
+ <button onclick="loadNotionSchedule()" style="padding: 6px 12px; font-size: 12px; background: var(--accent); color: white; border: none; border-radius: 4px; cursor: pointer;">์ƒˆ๋กœ๊ณ ์นจ</button>
469
+ </div>
470
+ </div>
471
+
472
+ <div id="notionLoading" class="loading-spinner" style="display: none;">
473
+ <div class="spinner-icon"></div>
474
+ <span id="notionLoadingText">Notion ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</span>
475
+ </div>
476
+
477
+ <!-- ์—๋Ÿฌ ๋ฐ ์บ์‹œ ์•Œ๋ฆผ ๋ฉ”์„ธ์ง€ -->
478
+ <div id="notionAlert" style="display: none; margin-bottom: 16px; padding: 12px 16px; border-radius: 8px; font-size: 14px;">
479
+ </div>
480
+
481
+ <!-- ํ†ต๊ณ„ ์š”๏ฟฝ๏ฟฝ๏ฟฝ ๋ฐ” -->
482
+ <div id="notionStatsBar" class="notion-stats-bar" style="display: none;">
483
+ <div class="notion-stat-box">
484
+ <div class="notion-stat-label">MONTH</div>
485
+ <div id="statMonth" class="notion-stat-value">-</div>
486
+ </div>
487
+ <div class="notion-stat-box">
488
+ <div class="notion-stat-label">TOTAL TASK</div>
489
+ <div id="statTotal" class="notion-stat-value">0</div>
490
+ </div>
491
+ <div class="notion-stat-box">
492
+ <div class="notion-stat-label">THIS WEEK</div>
493
+ <div id="statThisWeek" class="notion-stat-value">0</div>
494
+ </div>
495
+ <div class="notion-stat-box">
496
+ <div class="notion-stat-label">LAST WEEK</div>
497
+ <div id="statLastWeek" class="notion-stat-value">0</div>
498
+ </div>
499
+ </div>
500
+
501
+ <!-- ์ฃผ๊ฐ„ ๋Œ€์‹œ๋ณด๋“œ (์š”์ผ๋ณ„ ์ปฌ๋Ÿผ) -->
502
+ <div style="margin-top: 24px;">
503
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
504
+ <span style="background: #e8f0fe; color: #1a73e8; padding: 4px 12px; border-radius: 6px; font-size: 14px; font-weight: 700;">์ด๋ฒˆ ์ฃผ</span>
505
+ <h3 style="font-size: 16px; font-weight: 700; margin: 0; color: #3c4043;"> ์ฃผ๊ฐ„ ์—…๋ฌด ๋ณด๋“œ</h3>
506
+ </div>
507
+ <div id="notionWeeklyDashboard" class="notion-weekly-dashboard">
508
+ <!-- ์š”์ผ๋ณ„ ์ปฌ๋Ÿผ์ด ์—ฌ๊ธฐ์— ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค -->
509
+ </div>
510
+ </div>
511
+
512
+ <div style="margin-top: 40px;">
513
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
514
+ <span style="background: #f1f3f4; color: #5f6368; padding: 4px 12px; border-radius: 6px; font-size: 14px; font-weight: 700;">์ง€๋‚œ ์ฃผ</span>
515
+ <h3 style="font-size: 16px; font-weight: 700; margin: 0; color: #3c4043;"> ์ฃผ๊ฐ„ ์—…๋ฌด ๋ณด๋“œ</h3>
516
+ </div>
517
+ <div id="notionLastWeeklyDashboard" class="notion-weekly-dashboard">
518
+ <!-- ์š”์ผ๋ณ„ ์ปฌ๋Ÿผ์ด ์—ฌ๊ธฐ์— ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค -->
519
+ </div>
520
+ </div>
521
+
522
+ <div id="notionEmptyState" style="text-align: center; padding: 40px; color: var(--muted); border: 1px dashed var(--border); border-radius: 8px; display: none;">
523
+ ์ผ์ • ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
524
+ </div>
525
+ </div>
526
+
527
  <!-- ๊ณต์ง€์‚ฌํ•ญ ์„น์…˜ -->
528
  <div id="noticeContainer" class="notice-container">
529
  <!-- ๊ณต์ง€์‚ฌํ•ญ์ด ์—ฌ๊ธฐ์— ๋กœ๋“œ๋ฉ๋‹ˆ๋‹ค -->
 
571
  </div>
572
  {% endif %}
573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  {% if matched_data %}
575
  {% for project in matched_data %}
576
  <div style="margin-top: 40px;">
 
810
 
811
  async function loadNotionSchedule() {
812
  const loading = document.getElementById('notionLoading');
813
+ const loadingText = document.getElementById('notionLoadingText');
814
  const ganttSection = document.getElementById('notionGanttSection');
815
  const emptyState = document.getElementById('notionEmptyState');
816
+ const alertBox = document.getElementById('notionAlert');
817
 
818
+ if (loading) loading.style.display = 'flex';
819
+ if (loadingText) loadingText.textContent = '๐Ÿ”„ Notion ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...';
820
+ if (ganttSection) ganttSection.style.display = 'none';
821
+ if (emptyState) emptyState.style.display = 'none';
822
+ if (alertBox) alertBox.style.display = 'none';
823
 
824
  try {
825
  let nicknamesToFetch = [];
 
834
 
835
  let allResults = [];
836
  let dateProperty = null;
837
+ let hasNotionError = false;
838
+ let errorDetails = "";
839
 
840
  const total = nicknamesToFetch.length;
841
  for (let i = 0; i < total; i++) {
842
  const name = nicknamesToFetch[i];
843
+ loadingText.innerHTML = `๐Ÿ“‚ [${i+1}/${total}] <strong>${name || '์ „์ฒด'}</strong> ๋‹ด๋‹น์ž์˜ ์ผ์ • ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘...`;
844
 
845
  let url = `/api/admin/webtoon/agent/schedule?t=${new Date().getTime()}&page_size=50`;
846
  if (name) {
 
857
  if (!dateProperty && data.date_property) {
858
  dateProperty = data.date_property;
859
  }
860
+
861
+ // ์บ์‹œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ๊ฒฝ์šฐ ์•Œ๋ฆผ ํ‘œ์‹œ
862
+ if (data.is_from_cache) {
863
+ hasNotionError = true;
864
+ errorDetails = data.error || "Notion API ์—ฐ๊ฒฐ์— ์‹คํŒจํ•˜์—ฌ ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.";
865
+ }
866
  } else {
867
  console.error(`Failed to load schedule for ${name}:`, data.error);
868
+ hasNotionError = true;
869
+ errorDetails = data.error;
870
  }
871
  }
872
 
873
+ if (hasNotionError) {
874
+ alertBox.style.display = 'block';
875
+ alertBox.style.backgroundColor = '#fce8e6';
876
+ alertBox.style.color = '#c5221f';
877
+ alertBox.style.border = '1px solid #f5c2c7';
878
+ alertBox.innerHTML = `โš ๏ธ <strong>์—ฐ๊ฒฐ ์•Œ๋ฆผ:</strong> ${errorDetails}`;
879
+ }
880
+
881
+ loadingText.textContent = '๐Ÿ“Š ๋ถ„์„๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ฃผ๊ฐ„ ์—…๋ฌด ๋ณด๋“œ๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...';
882
 
883
  if (allResults.length === 0) {
884
  emptyState.style.display = 'block';
 
896
  }
897
 
898
  renderNotionGantt(uniqueResults, dateProperty);
899
+ if (ganttSection) ganttSection.style.display = 'block';
900
  } catch (error) {
901
  console.error('Notion schedule load failed:', error);
902
+ if (emptyState) {
903
+ emptyState.textContent = '๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message;
904
+ emptyState.style.display = 'block';
905
+ }
906
  } finally {
907
+ if (loading) {
908
+ loading.style.display = 'none';
909
+ if (loadingText) loadingText.innerHTML = `<div class="spinner-icon"></div> ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...`;
910
+ }
911
  }
912
  }
913
 
914
  function renderNotionGantt(results, datePropName) {
915
  const tasks = [];
916
 
917
+ // ์ฃผ๊ฐ„ ์ผ์ •์„ ์œ„ํ•œ ์ปจํ…Œ์ด๋„ˆ ์ดˆ๊ธฐํ™”
918
+ const dashboard = document.getElementById('notionWeeklyDashboard');
919
+ const lastDashboard = document.getElementById('notionLastWeeklyDashboard');
920
+ const statsBar = document.getElementById('notionStatsBar');
921
+
922
+ dashboard.innerHTML = '';
923
+ lastDashboard.innerHTML = '';
924
+ dashboard.style.display = 'grid';
925
+ lastDashboard.style.display = 'grid';
926
+ statsBar.style.display = 'grid';
927
+
928
+ // ๋‚ ์งœ ๊ณ„์‚ฐ (์˜ค๋Š˜ ๊ธฐ์ค€ ์ด๋ฒˆ ์ฃผ ์›”์š”์ผ ~ ๋‹ค์Œ ์ฃผ ์ผ์š”์ผ)
929
+ const now = new Date();
930
+ const todayStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
931
+
932
+ const day = now.getDay();
933
+ const diffToMonday = (day === 0 ? 6 : day - 1);
934
+
935
+ const thisMonday = new Date(now);
936
+ thisMonday.setDate(now.getDate() - diffToMonday);
937
+ thisMonday.setHours(0,0,0,0);
938
+
939
+ const lastMonday = new Date(thisMonday);
940
+ lastMonday.setDate(thisMonday.getDate() - 7);
941
+
942
+ const thisSunday = new Date(thisMonday);
943
+ thisSunday.setDate(thisMonday.getDate() + 6);
944
+ thisSunday.setHours(23,59,59,999);
945
+
946
+ const nextMonday = new Date(thisMonday);
947
+ nextMonday.setDate(thisMonday.getDate() + 7);
948
+ nextMonday.setHours(0,0,0,0);
949
+
950
+ const nextSunday = new Date(nextMonday);
951
+ nextSunday.setDate(nextMonday.getDate() + 6);
952
+ nextSunday.setHours(23,59,59,999);
953
+
954
+ const toIsoDate = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
955
+ const formatDate = (d) => `${d.getMonth() + 1}/${d.getDate()}`;
956
+
957
+ const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
958
+ document.getElementById('statMonth').textContent = monthNames[now.getMonth()];
959
+
960
+ // ์š”์ผ ์ปฌ๋Ÿผ ์ƒ์„ฑ (์ด๋ฒˆ ์ฃผ & ์ง€๋‚œ ์ฃผ)
961
+ const dayNames = ['์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ', '์ผ'];
962
+ const thisDayColumns = {};
963
+ const lastDayColumns = {};
964
+
965
+ for (let i = 0; i < 7; i++) {
966
+ const dayDt = new Date(thisMonday);
967
+ dayDt.setDate(thisMonday.getDate() + i);
968
+ const dayStr = toIsoDate(dayDt);
969
+
970
+ const col = document.createElement('div');
971
+ col.className = `notion-day-column ${dayStr === todayStr ? 'today' : ''}`;
972
+ col.innerHTML = `
973
+ <div class="notion-day-header">
974
+ <div class="notion-day-name">${dayNames[i]}์š”์ผ</div>
975
+ <div class="notion-day-date">${formatDate(dayDt)}</div>
976
+ </div>
977
+ <div id="this-day-tasks-${dayStr}" class="notion-day-tasks"></div>
978
+ `;
979
+ dashboard.appendChild(col);
980
+ thisDayColumns[dayStr] = col.querySelector('.notion-day-tasks');
981
+ }
982
+
983
+ for (let i = 0; i < 7; i++) {
984
+ const dayDt = new Date(lastMonday);
985
+ dayDt.setDate(lastMonday.getDate() + i);
986
+ const dayStr = toIsoDate(dayDt);
987
+
988
+ const col = document.createElement('div');
989
+ col.className = 'notion-day-column';
990
+ col.innerHTML = `
991
+ <div class="notion-day-header">
992
+ <div class="notion-day-name">${dayNames[i]}์š”์ผ</div>
993
+ <div class="notion-day-date">${formatDate(dayDt)}</div>
994
+ </div>
995
+ <div id="last-day-tasks-${dayStr}" class="notion-day-tasks"></div>
996
+ `;
997
+ lastDashboard.appendChild(col);
998
+ lastDayColumns[dayStr] = col.querySelector('.notion-day-tasks');
999
+ }
1000
+
1001
+ let totalCount = results.length;
1002
+ let thisWeekCount = 0;
1003
+ let lastWeekCount = 0;
1004
+
1005
  results.forEach((item, idx) => {
1006
  const props = item.properties;
1007
 
 
1026
  let endDate = dateDetail.end || startDate;
1027
 
1028
  if (startDate) {
1029
+ const startDateStr = startDate.split('T')[0];
1030
+ const endDateStr = endDate.split('T')[0];
 
1031
 
1032
+ const taskCardHtml = `
1033
+ <div class="notion-task-card">
1034
+ <div class="notion-task-title" title="${taskName}">
1035
+ <span class="notion-task-badge badge-notion">NOTION</span>${taskName}
1036
+ </div>
1037
+ <div class="notion-task-meta">
1038
+ <span>๐Ÿ“… ${startDateStr === endDateStr ? startDateStr.slice(5) : startDateStr.slice(5) + '-' + endDateStr.slice(5)}</span>
1039
+ </div>
1040
+ </div>
1041
+ `;
1042
+
1043
+ // ์ด๋ฒˆ ์ฃผ ์š”์ผ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ์— ๋ฐฐ์น˜
1044
+ for (const [dayStr, container] of Object.entries(thisDayColumns)) {
1045
+ if (startDateStr <= dayStr && endDateStr >= dayStr) {
1046
+ container.insertAdjacentHTML('beforeend', taskCardHtml);
1047
+ }
1048
+ }
1049
+
1050
+ // ์ง€๋‚œ ์ฃผ ์š”์ผ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ์— ๋ฐฐ์น˜
1051
+ for (const [dayStr, container] of Object.entries(lastDayColumns)) {
1052
+ if (startDateStr <= dayStr && endDateStr >= dayStr) {
1053
+ container.insertAdjacentHTML('beforeend', taskCardHtml);
1054
+ }
1055
+ }
1056
+
1057
+ // ํ†ต๊ณ„์šฉ ๊ณ„์‚ฐ
1058
+ const thisSunStr = toIsoDate(thisSunday);
1059
+ const thisMonStr = toIsoDate(thisMonday);
1060
+ const lastSunStr = toIsoDate(new Date(thisMonday.getTime() - 86400000));
1061
+ const lastMonStr = toIsoDate(lastMonday);
1062
+
1063
+ if (startDateStr <= thisSunStr && endDateStr >= thisMonStr) {
1064
+ thisWeekCount++;
1065
+ } else if (startDateStr <= lastSunStr && endDateStr >= lastMonStr) {
1066
+ lastWeekCount++;
1067
+ }
1068
  }
1069
  });
 
 
 
 
 
 
1070
 
1071
+ // (์˜ˆ์ •) ์ด์ฃผ์˜ ํ•  ์ผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด๋“œ์— ์ถ”๊ฐ€
1072
+ if (matchedData && matchedData.length > 0) {
1073
+ matchedData.forEach(project => {
1074
+ const fileName = project.file_name;
1075
+ project.episodes.forEach(ep => {
1076
+ const epNum = ep.episode_num;
1077
+ if (ep.stages) {
1078
+ ep.stages.forEach(st => {
1079
+ const stageName = getStageName(st.stage_key, st);
1080
+ const taskName = `[${fileName}] ${epNum}ํ™” ${stageName}`;
1081
+ const startDateStr = st.start_date;
1082
+ const endDateStr = st.end_date;
1083
 
1084
+ const taskCardHtml = `
1085
+ <div class="notion-task-card planned">
1086
+ <div class="notion-task-title" title="${taskName}">
1087
+ <span class="notion-task-badge badge-planned">์˜ˆ์ •</span>${taskName}
1088
+ </div>
1089
+ <div class="notion-task-meta">
1090
+ <span>๐Ÿ“… ${startDateStr === endDateStr ? startDateStr.slice(5) : startDateStr.slice(5) + '-' + endDateStr.slice(5)}</span>
1091
+ </div>
1092
+ </div>
1093
+ `;
 
1094
 
1095
+ // ์ด๋ฒˆ ์ฃผ ์š”์ผ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ์— ๋ฐฐ์น˜
1096
+ for (const [dayStr, container] of Object.entries(thisDayColumns)) {
1097
+ if (startDateStr <= dayStr && endDateStr >= dayStr) {
1098
+ container.insertAdjacentHTML('beforeend', taskCardHtml);
1099
+ }
1100
+ }
 
 
 
 
 
 
 
1101
 
1102
+ // ์ง€๋‚œ ์ฃผ ์š”์ผ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ์— ๋ฐฐ์น˜
1103
+ for (const [dayStr, container] of Object.entries(lastDayColumns)) {
1104
+ if (startDateStr <= dayStr && endDateStr >= dayStr) {
1105
+ container.insertAdjacentHTML('beforeend', taskCardHtml);
1106
+ }
1107
+ }
1108
+ });
1109
+ }
1110
+ });
1111
+ });
1112
+ }
1113
 
1114
+ document.getElementById('statTotal').textContent = totalCount;
1115
+ document.getElementById('statThisWeek').textContent = thisWeekCount;
1116
+ document.getElementById('statLastWeek').textContent = lastWeekCount;
1117
+
1118
+ // ๋ฐ์ดํ„ฐ ์—†๋Š” ์š”์ผ ์ฒ˜๋ฆฌ
1119
+ for (const dayStr in thisDayColumns) {
1120
+ if (thisDayColumns[dayStr].innerHTML === '') {
1121
+ thisDayColumns[dayStr].innerHTML = '<div style="text-align: center; padding: 20px; color: #9aa0a6; font-size: 11px; font-style: italic;">์ผ์ • ์—†์Œ</div>';
1122
+ }
1123
+ }
1124
+ for (const dayStr in lastDayColumns) {
1125
+ if (lastDayColumns[dayStr].innerHTML === '') {
1126
+ lastDayColumns[dayStr].innerHTML = '<div style="text-align: center; padding: 20px; color: #9aa0a6; font-size: 11px; font-style: italic;">์ผ์ • ์—†์Œ</div>';
1127
+ }
1128
+ }
1129
+
1130
+ if (results.length === 0) {
1131
+ document.getElementById('notionEmptyState').style.display = 'block';
1132
+ document.getElementById('notionWeeklyDashboard').parentElement.style.display = 'none';
1133
+ document.getElementById('notionLastWeeklyDashboard').parentElement.style.display = 'none';
1134
+ document.getElementById('notionStatsBar').style.display = 'none';
1135
+ return;
1136
+ }
1137
  }
1138
 
1139
  function formatValue(val) {