supernote 0.13.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. supernote/__init__.py +28 -0
  2. supernote/alembic/__init__.py +0 -0
  3. supernote/alembic/env.py +121 -0
  4. supernote/alembic/script.py.mako +28 -0
  5. supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
  6. supernote/alembic.ini +149 -0
  7. supernote/cli/__init__.py +1 -0
  8. supernote/cli/admin.py +178 -0
  9. supernote/cli/client.py +434 -0
  10. supernote/cli/main.py +61 -0
  11. supernote/cli/notebook.py +313 -0
  12. supernote/cli/server.py +89 -0
  13. supernote/client/__init__.py +28 -0
  14. supernote/client/admin.py +76 -0
  15. supernote/client/api.py +135 -0
  16. supernote/client/api_model.py +231 -0
  17. supernote/client/auth.py +84 -0
  18. supernote/client/client.py +365 -0
  19. supernote/client/device.py +361 -0
  20. supernote/client/exceptions.py +33 -0
  21. supernote/client/extended.py +20 -0
  22. supernote/client/hashing.py +50 -0
  23. supernote/client/login_client.py +154 -0
  24. supernote/client/schedule.py +149 -0
  25. supernote/client/summary.py +156 -0
  26. supernote/client/web.py +213 -0
  27. supernote/models/__init__.py +14 -0
  28. supernote/models/auth.py +380 -0
  29. supernote/models/base.py +266 -0
  30. supernote/models/equipment.py +276 -0
  31. supernote/models/extended.py +124 -0
  32. supernote/models/file_common.py +95 -0
  33. supernote/models/file_device.py +571 -0
  34. supernote/models/file_web.py +602 -0
  35. supernote/models/schedule.py +663 -0
  36. supernote/models/summary.py +830 -0
  37. supernote/models/system.py +431 -0
  38. supernote/models/user.py +638 -0
  39. supernote/notebook/__init__.py +67 -0
  40. supernote/notebook/color.py +93 -0
  41. supernote/notebook/converter.py +560 -0
  42. supernote/notebook/decoder.py +398 -0
  43. supernote/notebook/exceptions.py +43 -0
  44. supernote/notebook/fileformat.py +463 -0
  45. supernote/notebook/manipulator.py +420 -0
  46. supernote/notebook/parser.py +737 -0
  47. supernote/notebook/utils.py +49 -0
  48. supernote/py.typed +0 -0
  49. supernote/server/__init__.py +12 -0
  50. supernote/server/app.py +428 -0
  51. supernote/server/config.py +265 -0
  52. supernote/server/constants.py +26 -0
  53. supernote/server/db/__init__.py +9 -0
  54. supernote/server/db/base.py +8 -0
  55. supernote/server/db/migrations.py +44 -0
  56. supernote/server/db/models/__init__.py +23 -0
  57. supernote/server/db/models/device.py +17 -0
  58. supernote/server/db/models/file.py +107 -0
  59. supernote/server/db/models/kv.py +17 -0
  60. supernote/server/db/models/login_record.py +22 -0
  61. supernote/server/db/models/note_processing.py +100 -0
  62. supernote/server/db/models/schedule.py +83 -0
  63. supernote/server/db/models/summary.py +122 -0
  64. supernote/server/db/models/user.py +30 -0
  65. supernote/server/db/session.py +88 -0
  66. supernote/server/events.py +63 -0
  67. supernote/server/exceptions.py +153 -0
  68. supernote/server/mcp/__init__.py +1 -0
  69. supernote/server/mcp/models.py +109 -0
  70. supernote/server/mcp/server.py +176 -0
  71. supernote/server/resources/__init__.py +0 -0
  72. supernote/server/resources/prompts/__init__.py +0 -0
  73. supernote/server/resources/prompts/ocr/common/context.md +19 -0
  74. supernote/server/resources/prompts/ocr/common/legend.md +11 -0
  75. supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
  76. supernote/server/resources/prompts/ocr/default/system.md +14 -0
  77. supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
  78. supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
  79. supernote/server/resources/prompts/summary/common/instruction.md +8 -0
  80. supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
  81. supernote/server/resources/prompts/summary/default/prompt.md +4 -0
  82. supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
  83. supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
  84. supernote/server/routes/admin.py +109 -0
  85. supernote/server/routes/auth.py +337 -0
  86. supernote/server/routes/decorators.py +13 -0
  87. supernote/server/routes/extended.py +139 -0
  88. supernote/server/routes/file_device.py +593 -0
  89. supernote/server/routes/file_web.py +597 -0
  90. supernote/server/routes/oss.py +317 -0
  91. supernote/server/routes/schedule.py +223 -0
  92. supernote/server/routes/summary.py +390 -0
  93. supernote/server/routes/system.py +57 -0
  94. supernote/server/services/__init__.py +15 -0
  95. supernote/server/services/blob.py +214 -0
  96. supernote/server/services/coordination.py +171 -0
  97. supernote/server/services/file.py +970 -0
  98. supernote/server/services/gemini.py +66 -0
  99. supernote/server/services/integrity.py +112 -0
  100. supernote/server/services/processor.py +331 -0
  101. supernote/server/services/processor_modules/__init__.py +145 -0
  102. supernote/server/services/processor_modules/gemini_embedding.py +112 -0
  103. supernote/server/services/processor_modules/gemini_ocr.py +149 -0
  104. supernote/server/services/processor_modules/page_hashing.py +220 -0
  105. supernote/server/services/processor_modules/png_conversion.py +107 -0
  106. supernote/server/services/processor_modules/summary.py +302 -0
  107. supernote/server/services/prompt_loader.py +133 -0
  108. supernote/server/services/schedule.py +161 -0
  109. supernote/server/services/search.py +250 -0
  110. supernote/server/services/summary.py +419 -0
  111. supernote/server/services/user.py +480 -0
  112. supernote/server/services/vfs.py +476 -0
  113. supernote/server/static/favicon.ico +0 -0
  114. supernote/server/static/index.html +186 -0
  115. supernote/server/static/js/api/client.js +463 -0
  116. supernote/server/static/js/components/FileCard.js +55 -0
  117. supernote/server/static/js/components/FileViewer.js +137 -0
  118. supernote/server/static/js/components/LoginCard.js +64 -0
  119. supernote/server/static/js/components/MoveModal.js +74 -0
  120. supernote/server/static/js/components/RenameModal.js +39 -0
  121. supernote/server/static/js/components/SummaryPanel.js +101 -0
  122. supernote/server/static/js/components/SystemPanel.js +158 -0
  123. supernote/server/static/js/composables/useFileSystem.js +116 -0
  124. supernote/server/static/js/main.js +241 -0
  125. supernote/server/static/style.css +14 -0
  126. supernote/server/utils/hashing.py +18 -0
  127. supernote/server/utils/note_content.py +76 -0
  128. supernote/server/utils/paths.py +58 -0
  129. supernote/server/utils/rate_limit.py +52 -0
  130. supernote/server/utils/tasks.py +71 -0
  131. supernote/server/utils/unique_id.py +48 -0
  132. supernote/server/utils/url_signer.py +218 -0
  133. supernote-0.13.6.dist-info/METADATA +183 -0
  134. supernote-0.13.6.dist-info/RECORD +138 -0
  135. supernote-0.13.6.dist-info/WHEEL +5 -0
  136. supernote-0.13.6.dist-info/entry_points.txt +3 -0
  137. supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
  138. supernote-0.13.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,302 @@
1
+ import json
2
+ import logging
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from mashumaro.jsonschema import build_json_schema
8
+ from mashumaro.mixins.json import DataClassJSONMixin
9
+ from sqlalchemy import select
10
+
11
+ from supernote.models.summary import (
12
+ METADATA_SEGMENTS,
13
+ AddSummaryDTO,
14
+ UpdateSummaryDTO,
15
+ )
16
+ from supernote.server.config import ServerConfig
17
+ from supernote.server.db.models.file import UserFileDO
18
+ from supernote.server.db.models.note_processing import NotePageContentDO
19
+ from supernote.server.db.models.user import UserDO
20
+ from supernote.server.db.session import DatabaseSessionManager
21
+ from supernote.server.services.file import FileService
22
+ from supernote.server.services.gemini import GeminiService
23
+ from supernote.server.services.processor_modules import ProcessorModule
24
+ from supernote.server.services.prompt_loader import PROMPT_LOADER, PromptId
25
+ from supernote.server.services.summary import SummaryService
26
+ from supernote.server.utils.note_content import format_page_metadata
27
+ from supernote.server.utils.paths import get_summary_id, get_transcript_id
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # Define structured output schema
33
+ @dataclass
34
+ class SummarySegment(DataClassJSONMixin):
35
+ date_range: str = field(
36
+ metadata={
37
+ "description": "The date range covered by this segment (e.g., '2023-10-27', 'Week of Oct 27')."
38
+ }
39
+ )
40
+ summary: str = field(
41
+ metadata={
42
+ "description": "A concise summary of the events, tasks, and notes for this period."
43
+ }
44
+ )
45
+ extracted_dates: List[str] = field(
46
+ metadata={
47
+ "description": "List of specific dates derived from the content in ISO 8601 format (YYYY-MM-DD)."
48
+ }
49
+ )
50
+ page_refs: List[int] = field(
51
+ metadata={
52
+ "description": "List of 1-indexed page numbers typically found in the text as '--- Page X ---'."
53
+ }
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class SummaryResponse(DataClassJSONMixin):
59
+ segments: List[SummarySegment] = field(
60
+ metadata={
61
+ "description": "List of summary segments extracted from the transcript."
62
+ }
63
+ )
64
+
65
+
66
+ class SummaryModule(ProcessorModule):
67
+ """Module responsible for aggregating OCR text and generating summaries for a note."""
68
+
69
+ def __init__(
70
+ self,
71
+ file_service: FileService,
72
+ config: ServerConfig,
73
+ gemini_service: GeminiService,
74
+ summary_service: SummaryService,
75
+ ) -> None:
76
+ self.file_service = file_service
77
+ self.config = config
78
+ self.gemini_service = gemini_service
79
+ self.summary_service = summary_service
80
+
81
+ @property
82
+ def name(self) -> str:
83
+ return "SummaryModule"
84
+
85
+ @property
86
+ def task_type(self) -> str:
87
+ return "SUMMARY_GENERATION"
88
+
89
+ async def run_if_needed(
90
+ self,
91
+ file_id: int,
92
+ session_manager: DatabaseSessionManager,
93
+ page_index: Optional[int] = None,
94
+ page_id: Optional[str] = None,
95
+ ) -> bool:
96
+ """Determines if summary generation is needed."""
97
+ # Summary is a file-level task (global), not page-level.
98
+ if page_index is not None:
99
+ return False
100
+
101
+ if not self.gemini_service.is_configured:
102
+ return False
103
+
104
+ if not await super().run_if_needed(file_id, session_manager, page_index):
105
+ return False
106
+
107
+ return True
108
+
109
+ async def process(
110
+ self,
111
+ file_id: int,
112
+ session_manager: DatabaseSessionManager,
113
+ page_index: Optional[int] = None,
114
+ page_id: Optional[str] = None,
115
+ **kwargs: object,
116
+ ) -> None:
117
+ """
118
+ Generates an AI summary for the given file.
119
+
120
+ 1. Aggregates all OCR text for the file.
121
+ 2. Sends to Gemini for summarization and date extraction.
122
+ 3. Stores the result as a new Summary.
123
+ """
124
+ logger.info(f"Starting summary generation for file_id={file_id}")
125
+
126
+ async with session_manager.session() as session:
127
+ # 1. Fetch File Info
128
+ file_do = await session.get(UserFileDO, file_id)
129
+ if not file_do:
130
+ logger.error(f"File {file_id} not found.")
131
+ return
132
+
133
+ user: Optional[UserDO] = await session.get(UserDO, file_do.user_id)
134
+ if not user or not user.email:
135
+ logger.error(f"User for file {file_id} not found.")
136
+ return
137
+ user_email = user.email
138
+
139
+ # 2. Key Generation
140
+ file_basis = file_do.storage_key or str(file_do.id)
141
+ summary_uuid = get_summary_id(file_basis)
142
+ transcript_uuid = get_transcript_id(file_basis)
143
+
144
+ # 3. Aggregate OCR Text
145
+ # We want to format the text with page markers so the model can cite them.
146
+ stmt = (
147
+ select(NotePageContentDO)
148
+ .where(NotePageContentDO.file_id == file_id)
149
+ .order_by(NotePageContentDO.page_index)
150
+ )
151
+ result = await session.execute(stmt)
152
+ pages = result.scalars().all()
153
+
154
+ if not pages:
155
+ logger.info(f"No OCR pages found for file {file_id}. Skipping summary.")
156
+ return
157
+
158
+ # Format transcript with Page markers and inferred dates
159
+ text_parts = []
160
+ for p in pages:
161
+ if p.text_content:
162
+ metadata = format_page_metadata(
163
+ page_index=p.page_index,
164
+ page_id=p.page_id or "",
165
+ file_name=file_do.file_name,
166
+ notebook_create_time=file_do.create_time,
167
+ include_section_divider=True,
168
+ )
169
+ text_parts.append(f"{metadata}\n{p.text_content}")
170
+
171
+ full_text = "\n\n".join(text_parts)
172
+
173
+ # 4. Generate Transcript Summary (Preserve existing functionality)
174
+ # Store the raw aggregated text as a 'transcript' summary type first
175
+ # This is a good baseline to have.
176
+ await self._upsert_summary(
177
+ user_email,
178
+ AddSummaryDTO(
179
+ file_id=file_id,
180
+ unique_identifier=transcript_uuid,
181
+ content=full_text,
182
+ data_source="OCR",
183
+ source_path=file_do.file_name,
184
+ ),
185
+ )
186
+
187
+ # 5. Generate AI Summary using Gemini
188
+ # Determine prompt based on filename/type
189
+ custom_type = Path(file_do.file_name).stem.lower()
190
+
191
+ # Load Prompt using specialized logic: Common + (Custom OR Default)
192
+ prompt_template = PROMPT_LOADER.get_prompt(
193
+ PromptId.SUMMARY_GENERATION, custom_type=custom_type
194
+ )
195
+ prompt = f"{prompt_template}\n\nTRANSCRIPT:\n{full_text}"
196
+
197
+ try:
198
+ response = await self.gemini_service.generate_content(
199
+ model=self.config.gemini_ocr_model,
200
+ contents=prompt,
201
+ config={
202
+ "response_mime_type": "application/json",
203
+ "response_json_schema": build_json_schema(
204
+ SummaryResponse
205
+ ).to_dict(),
206
+ },
207
+ )
208
+ except Exception as e:
209
+ logger.error(f"Failed to generate AI summary for file {file_id}: {e}")
210
+ return
211
+
212
+ # Parse JSON response
213
+ ai_summary = "No summary generated."
214
+ metadata_str = None
215
+
216
+ if response.text:
217
+ try:
218
+ data = json.loads(response.text)
219
+ segments_data = data.get("segments", [])
220
+
221
+ # Format segments into Markdown and collect metadata
222
+ summary_parts = []
223
+ all_extracted_dates = []
224
+
225
+ # Check for page citations and build clean segment list for metadata
226
+ clean_segments = []
227
+
228
+ for seg in segments_data:
229
+ date_range = seg.get("date_range", "Unknown Date")
230
+ text = seg.get("summary", "")
231
+ dates = seg.get("extracted_dates", [])
232
+ page_refs = seg.get("page_refs", [])
233
+
234
+ # Markdown Formatting
235
+ header = f"## {date_range}"
236
+ # Add page citations to markdown too for readability
237
+ if page_refs:
238
+ # e.g. (Pages 45, 46)
239
+ pages_str = ", ".join(str(p) for p in page_refs)
240
+ header += f" (Pages {pages_str})"
241
+
242
+ summary_parts.append(f"{header}\n{text}")
243
+ all_extracted_dates.extend(dates)
244
+
245
+ clean_segments.append(
246
+ {
247
+ "date_range": date_range,
248
+ "summary": text, # Optional: could truncate or omit to save space
249
+ "extracted_dates": dates,
250
+ "page_refs": page_refs,
251
+ }
252
+ )
253
+
254
+ if summary_parts:
255
+ ai_summary = "\n\n".join(summary_parts)
256
+
257
+ # Construct rich metadata for retrieval
258
+ if clean_segments:
259
+ meta_obj = {METADATA_SEGMENTS: clean_segments}
260
+ metadata_str = json.dumps(meta_obj)
261
+ logger.info(
262
+ f"Generated metadata for file {file_id}: segments={len(clean_segments)}"
263
+ )
264
+
265
+ except json.JSONDecodeError:
266
+ logger.error(f"Failed to parse JSON response for file {file_id}")
267
+ ai_summary = response.text
268
+
269
+ await self._upsert_summary(
270
+ user_email,
271
+ AddSummaryDTO(
272
+ file_id=file_id,
273
+ unique_identifier=summary_uuid,
274
+ content=ai_summary,
275
+ data_source="GEMINI",
276
+ source_path=file_do.file_name,
277
+ metadata=metadata_str,
278
+ ),
279
+ )
280
+
281
+ async def _upsert_summary(self, user_email: str, dto: AddSummaryDTO) -> None:
282
+ """Helper to either add or update a summary based on its unique identifier."""
283
+ if not dto.unique_identifier:
284
+ logger.error("Cannot upsert summary without a unique identifier")
285
+ return
286
+
287
+ existing = await self.summary_service.get_summary_by_uuid(
288
+ user_email, dto.unique_identifier
289
+ )
290
+ if existing and existing.id is not None:
291
+ await self.summary_service.update_summary(
292
+ user_email,
293
+ UpdateSummaryDTO(
294
+ id=existing.id,
295
+ content=dto.content,
296
+ data_source=dto.data_source,
297
+ source_path=dto.source_path,
298
+ metadata=dto.metadata,
299
+ ),
300
+ )
301
+ else:
302
+ await self.summary_service.add_summary(user_email, dto)
@@ -0,0 +1,133 @@
1
+ import enum
2
+ import importlib.resources
3
+ import logging
4
+ from importlib.resources.abc import Traversable
5
+ from pathlib import Path
6
+ from typing import Dict, Optional, Union
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ RESOURCES_DIR = importlib.resources.files("supernote.server") / "resources" / "prompts"
11
+
12
+
13
+ class PromptId(str, enum.Enum):
14
+ OCR_TRANSCRIPTION = "ocr_transcription"
15
+ SUMMARY_GENERATION = "summary_generation"
16
+
17
+
18
+ CATEGORY_MAP = {
19
+ "ocr": PromptId.OCR_TRANSCRIPTION.value,
20
+ "summary": PromptId.SUMMARY_GENERATION.value,
21
+ }
22
+ COMMON = "common"
23
+ DEFAULT = "default"
24
+
25
+
26
+ class PromptLoader:
27
+ """Service to load and manage externalized prompts."""
28
+
29
+ def __init__(
30
+ self, resources_dir: Optional[Union[Path, Traversable]] = None
31
+ ) -> None:
32
+ self.resources_dir = resources_dir or RESOURCES_DIR
33
+ # Map: prompt_id -> (type -> prompt_text)
34
+ # type can be "common", "default", or specific custom types like "monthly"
35
+ self.prompts: Dict[str, Dict[str, str]] = {}
36
+ self._load_prompts()
37
+
38
+ def _load_prompts(self) -> None:
39
+ """Load all prompts from the resources directory into memory."""
40
+ if not self.resources_dir.is_dir():
41
+ logger.warning(f"Prompts directory not found at {self.resources_dir}")
42
+ return
43
+
44
+ # Initialize dicts for known prompt IDs
45
+ for pid in PromptId:
46
+ self.prompts[pid.value] = {}
47
+
48
+ try:
49
+ # Map categories to PromptId
50
+ for category, prompt_id in CATEGORY_MAP.items():
51
+ category_dir = self.resources_dir / category
52
+ if not category_dir.is_dir():
53
+ continue
54
+
55
+ if prompt_id not in self.prompts:
56
+ self.prompts[prompt_id] = {}
57
+
58
+ # 1. Load Common Prompts (Always On)
59
+ common_dir = category_dir / COMMON
60
+ if common_dir.is_dir():
61
+ common_text = self._read_prompts_from_dir(common_dir)
62
+ if common_text:
63
+ self.prompts[prompt_id][COMMON] = common_text
64
+
65
+ # 2. Load Default Prompts
66
+ default_dir = category_dir / DEFAULT
67
+ if default_dir.is_dir():
68
+ default_text = self._read_prompts_from_dir(default_dir)
69
+ if default_text:
70
+ self.prompts[prompt_id][DEFAULT] = default_text
71
+
72
+ # 3. Load Custom Prompts (sub-directories)
73
+ for item in category_dir.iterdir():
74
+ if item.is_dir() and item.name not in [COMMON, DEFAULT]:
75
+ custom_type = item.name.lower()
76
+ custom_text = self._read_prompts_from_dir(item)
77
+ if custom_text:
78
+ self.prompts[prompt_id][custom_type] = custom_text
79
+
80
+ logger.info(f"Loaded prompts from {self.resources_dir}")
81
+
82
+ except Exception as e:
83
+ logger.error(f"Failed to load prompts from {self.resources_dir}: {e}")
84
+
85
+ def _read_prompts_from_dir(self, directory: Union[Path, Traversable]) -> str:
86
+ """Read and concatenate all .md files in a directory."""
87
+ prompts = []
88
+ # Use iterdir instead of glob for Traversable compatibility
89
+ files = sorted(
90
+ [f for f in directory.iterdir() if f.name.endswith(".md")],
91
+ key=lambda x: x.name,
92
+ )
93
+ for file_path in files:
94
+ if file_path.is_file():
95
+ prompts.append(file_path.read_text(encoding="utf-8").strip())
96
+ return "\n\n".join(prompts)
97
+
98
+ def get_prompt(self, prompt_id: str, custom_type: Optional[str] = None) -> str:
99
+ """Retrieve a prompt by its ID, optionally overridden by a custom type.
100
+
101
+ Logic: Common + (Custom if exists else Default)
102
+ """
103
+ if prompt_id not in self.prompts:
104
+ raise ValueError(f"Prompt ID '{prompt_id}' not found.")
105
+
106
+ type_map = self.prompts[prompt_id]
107
+
108
+ parts = []
109
+
110
+ # 1. Add Common
111
+ if COMMON in type_map:
112
+ parts.append(type_map[COMMON])
113
+
114
+ # 2. Add Specific (Custom or Default)
115
+ specific_prompt = None
116
+ if custom_type and custom_type in type_map:
117
+ logger.info(f"Using custom prompt '{custom_type}' for {prompt_id}")
118
+ specific_prompt = type_map[custom_type]
119
+ elif DEFAULT in type_map:
120
+ specific_prompt = type_map[DEFAULT]
121
+
122
+ if specific_prompt:
123
+ parts.append(specific_prompt)
124
+ elif not parts:
125
+ # If no common and no specific/default, that's an issue
126
+ raise ValueError(
127
+ f"No prompt content found for '{prompt_id}' (Custom: {custom_type})"
128
+ )
129
+
130
+ return "\n\n".join(parts)
131
+
132
+
133
+ PROMPT_LOADER = PromptLoader()
@@ -0,0 +1,161 @@
1
+ import logging
2
+ from typing import Any, Optional
3
+
4
+ from sqlalchemy import delete, select, update
5
+
6
+ from supernote.server.db.models.schedule import ScheduleTaskDO, ScheduleTaskGroupDO
7
+ from supernote.server.db.session import DatabaseSessionManager
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ MAX_TITLE_LENGTH = 255
12
+ MAX_DETAIL_LENGTH = 1 * 1024 * 1024 # 1MB
13
+
14
+
15
+ class ScheduleService:
16
+ """Schedule service."""
17
+
18
+ def __init__(self, session_manager: DatabaseSessionManager):
19
+ """Initialize the schedule service."""
20
+ self.session_manager = session_manager
21
+
22
+ # Task Group Operations
23
+
24
+ async def create_group(self, user_id: int, title: str) -> ScheduleTaskGroupDO:
25
+ """Create a new task group."""
26
+ if len(title) > MAX_TITLE_LENGTH:
27
+ raise ValueError("Title is too long")
28
+ async with self.session_manager.session() as session:
29
+ group = ScheduleTaskGroupDO(user_id=user_id, title=title)
30
+ session.add(group)
31
+ # Flush to generate ID
32
+ await session.flush()
33
+ # Commit to persist
34
+ await session.commit()
35
+ # Refresh to get defaults if any
36
+ await session.refresh(group)
37
+ return group
38
+
39
+ async def list_groups(self, user_id: int) -> list[ScheduleTaskGroupDO]:
40
+ """List all task groups for a user."""
41
+ async with self.session_manager.session() as session:
42
+ stmt = (
43
+ select(ScheduleTaskGroupDO)
44
+ .where(ScheduleTaskGroupDO.user_id == user_id)
45
+ .order_by(ScheduleTaskGroupDO.create_time.desc())
46
+ )
47
+ result = await session.execute(stmt)
48
+ return list(result.scalars().all())
49
+
50
+ async def delete_group(self, user_id: int, group_id: int) -> bool:
51
+ """Delete a task group. Returns True if found and deleted."""
52
+ # TODO: Handle cascade delete of tasks?
53
+ # For now, simplistic delete.
54
+ async with self.session_manager.session() as session:
55
+ stmt = delete(ScheduleTaskGroupDO).where(
56
+ ScheduleTaskGroupDO.user_id == user_id,
57
+ ScheduleTaskGroupDO.task_list_id == group_id,
58
+ )
59
+ result = await session.execute(stmt)
60
+ await session.commit()
61
+ return bool(result.rowcount > 0) # type: ignore[attr-defined]
62
+
63
+ # Task Operations
64
+
65
+ async def create_task(
66
+ self,
67
+ user_id: int,
68
+ group_id: int,
69
+ title: str,
70
+ detail: str = "",
71
+ status: str = "needsAction",
72
+ importance: str | None = None,
73
+ due_time: int | None = None,
74
+ recurrence: str | None = None,
75
+ is_reminder_on: bool = False,
76
+ ) -> ScheduleTaskDO:
77
+ """Create a new task."""
78
+ if len(title) > MAX_TITLE_LENGTH:
79
+ raise ValueError("Title is too long")
80
+ if len(detail) > MAX_DETAIL_LENGTH:
81
+ raise ValueError("Detail is too long")
82
+ async with self.session_manager.session() as session:
83
+ task = ScheduleTaskDO(
84
+ user_id=user_id,
85
+ task_list_id=group_id,
86
+ title=title,
87
+ detail=detail,
88
+ status=status,
89
+ importance=importance,
90
+ due_time=due_time,
91
+ recurrence=recurrence,
92
+ is_reminder_on=is_reminder_on,
93
+ )
94
+ session.add(task)
95
+ await session.flush()
96
+ await session.commit()
97
+ await session.refresh(task)
98
+ return task
99
+
100
+ async def list_tasks(
101
+ self,
102
+ user_id: int,
103
+ group_id: int | None = None,
104
+ ) -> list[ScheduleTaskDO]:
105
+ """List tasks for a user, optionally filtered by group."""
106
+ async with self.session_manager.session() as session:
107
+ query = select(ScheduleTaskDO).where(ScheduleTaskDO.user_id == user_id)
108
+ if group_id is not None:
109
+ query = query.where(ScheduleTaskDO.task_list_id == group_id)
110
+
111
+ query = query.order_by(ScheduleTaskDO.create_time.desc())
112
+ result = await session.execute(query)
113
+ return list(result.scalars().all())
114
+
115
+ async def update_task(
116
+ self, user_id: int, task_id: int, **kwargs: Any
117
+ ) -> Optional[ScheduleTaskDO]:
118
+ """Update a task."""
119
+ # Clean kwargs to only allow update of specific fields?
120
+ # For simplicity, we assume caller passes valid fields that match DO columns.
121
+ allowed_fields = {
122
+ "title",
123
+ "detail",
124
+ "status",
125
+ "importance",
126
+ "due_time",
127
+ "completed_time",
128
+ "recurrence",
129
+ "is_reminder_on",
130
+ "task_list_id",
131
+ }
132
+ updates = {k: v for k, v in kwargs.items() if k in allowed_fields}
133
+
134
+ async with self.session_manager.session() as session:
135
+ stmt = (
136
+ update(ScheduleTaskDO)
137
+ .where(
138
+ ScheduleTaskDO.user_id == user_id, ScheduleTaskDO.task_id == task_id
139
+ )
140
+ .values(**updates)
141
+ .execution_options(synchronize_session="fetch")
142
+ )
143
+ await session.execute(stmt)
144
+ await session.commit()
145
+
146
+ # Retrieve updated
147
+ stmt_get = select(ScheduleTaskDO).where(
148
+ ScheduleTaskDO.user_id == user_id, ScheduleTaskDO.task_id == task_id
149
+ )
150
+ result = await session.execute(stmt_get)
151
+ return result.scalar_one_or_none()
152
+
153
+ async def delete_task(self, user_id: int, task_id: int) -> bool:
154
+ """Delete a task."""
155
+ async with self.session_manager.session() as session:
156
+ stmt = delete(ScheduleTaskDO).where(
157
+ ScheduleTaskDO.user_id == user_id, ScheduleTaskDO.task_id == task_id
158
+ )
159
+ result = await session.execute(stmt)
160
+ await session.commit()
161
+ return bool(result.rowcount > 0) # type: ignore[attr-defined]