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,241 @@
1
+ import { createApp, ref, onMounted, computed } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
2
+ import { useFileSystem } from './composables/useFileSystem.js';
3
+ import { setToken, getToken, login, logout, fetchProcessingStatus } from './api/client.js';
4
+ import FileCard from './components/FileCard.js';
5
+ import LoginCard from './components/LoginCard.js';
6
+ import FileViewer from './components/FileViewer.js';
7
+ import SystemPanel from './components/SystemPanel.js';
8
+ import MoveModal from './components/MoveModal.js';
9
+ import RenameModal from './components/RenameModal.js';
10
+
11
+ createApp({
12
+ components: {
13
+ FileCard,
14
+ LoginCard,
15
+ FileViewer,
16
+ SystemPanel,
17
+ MoveModal,
18
+ RenameModal
19
+ },
20
+ setup() {
21
+ // Auth State
22
+ const isLoggedIn = ref(false);
23
+ const loginError = ref(null);
24
+ const showSystemPanel = ref(false);
25
+
26
+ // UI State
27
+ const showNewFolderModal = ref(false);
28
+ const newFolderName = ref('');
29
+ const showMoveModal = ref(false);
30
+ const showRenameModal = ref(false);
31
+ const itemToRename = ref(null);
32
+ const selectedIds = ref([]);
33
+ const processingStatuses = ref({}); // fileId -> status string
34
+
35
+ // File System
36
+ const {
37
+ files,
38
+ currentDirectoryId,
39
+ isLoading,
40
+ error,
41
+ loadDirectory,
42
+ createNewFolder,
43
+ deleteSelectedItems,
44
+ moveSelectedItems,
45
+ uploadFiles,
46
+ renameSelectedItem
47
+ } = useFileSystem();
48
+
49
+ const view = ref('grid');
50
+ const selectedFile = ref(null);
51
+ const breadcrumbs = ref([{ id: "0", name: "Cloud" }]);
52
+
53
+ const folders = computed(() => files.value.filter(f => f.isDirectory));
54
+ const regularFiles = computed(() => files.value.filter(f => !f.isDirectory));
55
+
56
+ // Methods
57
+ async function openItem(item) {
58
+ if (item.isDirectory) {
59
+ currentDirectoryId.value = item.id;
60
+ breadcrumbs.value.push({ id: item.id, name: item.name });
61
+ selectedIds.value = [];
62
+ await loadDirectory(item.id);
63
+ } else {
64
+ selectedFile.value = item;
65
+ view.value = 'viewer';
66
+ }
67
+ }
68
+
69
+ async function navigateTo(index) {
70
+ const crumbs = breadcrumbs.value.slice(0, index + 1);
71
+ breadcrumbs.value = crumbs;
72
+ const target = crumbs[crumbs.length - 1];
73
+ view.value = 'grid';
74
+ selectedIds.value = [];
75
+ await loadDirectory(target.id);
76
+ }
77
+
78
+ // Selection
79
+ function toggleSelection(id) {
80
+ const index = selectedIds.value.indexOf(id);
81
+ if (index > -1) {
82
+ selectedIds.value.splice(index, 1);
83
+ } else {
84
+ selectedIds.value.push(id);
85
+ }
86
+ }
87
+
88
+ // Actions
89
+ async function handleCreateFolder() {
90
+ if (!newFolderName.value) return;
91
+ try {
92
+ await createNewFolder(newFolderName.value);
93
+ showNewFolderModal.value = false;
94
+ newFolderName.value = '';
95
+ } catch (e) {
96
+ alert("Failed to create folder: " + e.message);
97
+ }
98
+ }
99
+
100
+ const fileInput = ref(null);
101
+ function triggerUpload() {
102
+ fileInput.value.click();
103
+ }
104
+
105
+ async function handleFileUpload(event) {
106
+ const selectedFiles = event.target.files;
107
+ if (selectedFiles.length === 0) return;
108
+ try {
109
+ await uploadFiles(selectedFiles);
110
+ } catch (e) {
111
+ alert("Upload failed: " + e.message);
112
+ } finally {
113
+ event.target.value = ''; // Reset input
114
+ }
115
+ }
116
+
117
+ async function handleDeleteSelected() {
118
+ if (!confirm(`Are you sure you want to delete ${selectedIds.value.length} items?`)) return;
119
+ try {
120
+ await deleteSelectedItems(selectedIds.value);
121
+ selectedIds.value = [];
122
+ } catch (e) {
123
+ alert("Delete failed: " + e.message);
124
+ }
125
+ }
126
+
127
+ function handleMoveSelected() {
128
+ showMoveModal.value = true;
129
+ }
130
+
131
+ async function onConfirmMove(targetDirId) {
132
+ try {
133
+ await moveSelectedItems(selectedIds.value, targetDirId);
134
+ selectedIds.value = [];
135
+ showMoveModal.value = false;
136
+ } catch (e) {
137
+ alert("Move failed: " + e.message);
138
+ }
139
+ }
140
+
141
+ function triggerRename(item) {
142
+ itemToRename.value = item;
143
+ showRenameModal.value = true;
144
+ }
145
+
146
+ async function onConfirmRename(newName) {
147
+ try {
148
+ await renameSelectedItem(itemToRename.value.id, newName);
149
+ showRenameModal.value = false;
150
+ itemToRename.value = null;
151
+ } catch (e) {
152
+ alert("Rename failed: " + e.message);
153
+ }
154
+ }
155
+
156
+ async function handleLogin({ email, password }) {
157
+ loginError.value = null;
158
+ try {
159
+ await login(email, password);
160
+ isLoggedIn.value = true;
161
+ await loadDirectory();
162
+ } catch (e) {
163
+ loginError.value = e.message;
164
+ alert(e.message);
165
+ }
166
+ }
167
+
168
+ function handleLogout() {
169
+ logout();
170
+ }
171
+
172
+ onMounted(() => {
173
+ if (getToken()) {
174
+ isLoggedIn.value = true;
175
+ loadDirectory();
176
+ }
177
+
178
+ // Polling for processing status
179
+ setInterval(async () => {
180
+ if (!isLoggedIn.value || isLoading.value || files.value.length === 0) return;
181
+
182
+ const noteFileIds = files.value
183
+ .filter(f => f.extension === 'note')
184
+ .map(f => parseInt(f.id));
185
+
186
+ if (noteFileIds.length === 0) return;
187
+
188
+ try {
189
+ const result = await fetchProcessingStatus(noteFileIds);
190
+ if (result.success) {
191
+ processingStatuses.value = {
192
+ ...processingStatuses.value,
193
+ ...result.statusMap
194
+ };
195
+ }
196
+ } catch (e) {
197
+ console.error("Failed to poll status:", e);
198
+ }
199
+ }, 3000); // Every 3 seconds
200
+ });
201
+
202
+ return {
203
+ isLoggedIn,
204
+ handleLogin,
205
+ handleLogout,
206
+ view,
207
+ files,
208
+ folders,
209
+ regularFiles,
210
+ currentDirectoryId,
211
+ isLoading,
212
+ error,
213
+ breadcrumbs,
214
+ openItem,
215
+ navigateTo,
216
+ selectedFile,
217
+ showSystemPanel,
218
+
219
+ // New States
220
+ showNewFolderModal,
221
+ newFolderName,
222
+ showMoveModal,
223
+ showRenameModal,
224
+ itemToRename,
225
+ selectedIds,
226
+ fileInput,
227
+
228
+ // New Methods
229
+ toggleSelection,
230
+ handleCreateFolder,
231
+ triggerUpload,
232
+ handleFileUpload,
233
+ handleDeleteSelected,
234
+ handleMoveSelected,
235
+ onConfirmMove,
236
+ triggerRename,
237
+ onConfirmRename,
238
+ processingStatuses
239
+ };
240
+ }
241
+ }).mount('#app');
@@ -0,0 +1,14 @@
1
+ [v-cloak] { display: none; }
2
+
3
+ body {
4
+ font-family: 'Inter', sans-serif;
5
+ }
6
+
7
+ .animate-pulse-slow {
8
+ animation: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
9
+ }
10
+
11
+ @keyframes pulse {
12
+ 0%, 100% { opacity: 1; }
13
+ 50% { opacity: .7; }
14
+ }
@@ -0,0 +1,18 @@
1
+ """Module for hashing utilities."""
2
+
3
+ import hashlib
4
+
5
+
6
+ def sha256_string(s: str) -> str:
7
+ """Return SHA256 hex digest of a string."""
8
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
9
+
10
+
11
+ def hash_with_salt(content: str, salt: str) -> str:
12
+ """Hash content with salt using SHA256."""
13
+ return sha256_string(f"{content}{salt}")
14
+
15
+
16
+ def get_md5_hash(s: str) -> str:
17
+ """Return MD5 hex digest of a string."""
18
+ return hashlib.md5(s.encode("utf-8")).hexdigest()
@@ -0,0 +1,76 @@
1
+ import re
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from supernote.server.db.models.note_processing import NotePageContentDO
9
+
10
+
11
+ async def get_page_content_by_id(
12
+ session: AsyncSession, file_id: int, page_id: str
13
+ ) -> Optional[NotePageContentDO]:
14
+ """Retrieve NotePageContentDO by file_id and page_id using an existing session."""
15
+ return (
16
+ (
17
+ await session.execute(
18
+ select(NotePageContentDO)
19
+ .where(NotePageContentDO.file_id == file_id)
20
+ .where(NotePageContentDO.page_id == page_id)
21
+ )
22
+ )
23
+ .scalars()
24
+ .first()
25
+ )
26
+
27
+
28
+ def infer_page_date(page_id: str) -> datetime | None:
29
+ """Infers a date from a Supernote PAGEID string.
30
+
31
+ PAGEID usually looks like: P20231027123456... (P followed by YYYYMMDDHHMMSS)
32
+ """
33
+ if not page_id or not page_id.startswith("P"):
34
+ return None
35
+
36
+ # Try to extract YYYYYMMDD
37
+ match = re.match(r"P(\d{8})", page_id)
38
+ if not match:
39
+ return None
40
+
41
+ date_str = match.group(1)
42
+ try:
43
+ return datetime.strptime(date_str, "%Y%m%d")
44
+ except ValueError:
45
+ return None
46
+
47
+
48
+ def format_page_metadata(
49
+ page_index: int,
50
+ page_id: str,
51
+ file_name: Optional[str] = None,
52
+ notebook_create_time: Optional[int] = None,
53
+ include_section_divider: bool = False,
54
+ ) -> str:
55
+ """Formats a consistent metadata block for a notebook page.
56
+
57
+ Used in OCR prompts, transcripts, and summaries.
58
+ """
59
+ lines = [f"--- Page {page_index + 1} ---"]
60
+ if file_name:
61
+ lines.append(f"Notebook Filename: {file_name}")
62
+
63
+ if notebook_create_time:
64
+ nb_dt = datetime.fromtimestamp(notebook_create_time / 1000)
65
+ lines.append(f"Notebook Created: {nb_dt.strftime('%Y-%m-%d %H:%M:%S')}")
66
+
67
+ lines.append(f"Page ID: {page_id}")
68
+
69
+ inferred_date = infer_page_date(page_id)
70
+ if inferred_date:
71
+ lines.append(f"Page Date (Inferred): {inferred_date.strftime('%Y-%m-%d')}")
72
+
73
+ if include_section_divider:
74
+ lines.append("---")
75
+
76
+ return "\n".join(lines)
@@ -0,0 +1,58 @@
1
+ """Module for generating storage paths for various file types.
2
+
3
+ This contains storage keys and database paths.
4
+ """
5
+
6
+
7
+ def get_page_png_path(file_id: int, page_id: str) -> str:
8
+ """Get the blob storage path for a page PNG."""
9
+ return f"{file_id}/pages/{page_id}.png"
10
+
11
+
12
+ def get_file_chunk_path(object_name: str, part_number: int) -> str:
13
+ """Generate a storage path for a file chunk."""
14
+ return f"{object_name}.part.{part_number}"
15
+
16
+
17
+ def get_summary_id(file_basis: str) -> str:
18
+ """Generate a unique identifier for an AI summary."""
19
+ return f"{file_basis}-summary"
20
+
21
+
22
+ def get_transcript_id(file_basis: str) -> str:
23
+ """Generate a unique identifier for an OCR transcript."""
24
+ return f"{file_basis}-transcript"
25
+
26
+
27
+ def get_conversion_png_path(
28
+ user_id: int, file_id: int, page_index: int, file_md5: str
29
+ ) -> str:
30
+ """Generate a storage path for a converted PNG page."""
31
+ return f"conversions/{user_id}/{file_id}/page_{page_index}_{file_md5}.png"
32
+
33
+
34
+ def get_conversion_pdf_path(user_id: int, file_id: int, file_md5: str) -> str:
35
+ """Generate a storage path for a converted PDF note."""
36
+ return f"conversions/{user_id}/{file_id}/note_{file_md5}.pdf"
37
+
38
+
39
+ def generate_inner_name(filename: str, equipment_no: str | None) -> str:
40
+ """Generate a system-of-record inner name.
41
+
42
+ Format: {UUID}-{Tail}.{Ext}
43
+ - UUID: Random UUID
44
+ - Tail: Last 3 chars of equipment number (or full if short, default '000')
45
+ - Ext: Original file extension
46
+ """
47
+ import os
48
+ import uuid
49
+
50
+ req_uuid = uuid.uuid4()
51
+ ext = os.path.splitext(filename)[1]
52
+
53
+ # Determine tail
54
+ tail = "000"
55
+ if equipment_no:
56
+ tail = equipment_no[-3:] if len(equipment_no) >= 3 else equipment_no
57
+
58
+ return f"{req_uuid}-{tail}{ext}"
@@ -0,0 +1,52 @@
1
+ import time
2
+
3
+ from supernote.server.exceptions import RateLimitExceeded
4
+ from supernote.server.services.coordination import CoordinationService
5
+
6
+ # Constants for Rate Limiting
7
+ LIMIT_LOGIN_IP_MAX = 20
8
+ LIMIT_LOGIN_IP_WINDOW = 60 # 1 minute
9
+ LOGIN_KEY_IP = "login:ip"
10
+
11
+ LIMIT_LOGIN_ACCOUNT_MAX = 10
12
+ LIMIT_LOGIN_ACCOUNT_WINDOW = 60 # 1 minute
13
+ LOGIN_KEY_ACCOUNT = "login:account"
14
+
15
+ LIMIT_PW_RESET_MAX = 5
16
+ LIMIT_PW_RESET_WINDOW = 3600 # 1 hour
17
+ RESET_KEY_ACCOUNT = "reset:account"
18
+ RESET_KEY_IP = "reset:ip"
19
+
20
+
21
+ class RateLimiter:
22
+ """Simple distributed rate limiter using Fixed Window Counter algorithm."""
23
+
24
+ def __init__(self, coordination_service: CoordinationService):
25
+ self._coordination_service = coordination_service
26
+
27
+ async def check(self, key: str, limit: int, window: int) -> None:
28
+ """Check if the action is allowed.
29
+
30
+ Args:
31
+ key: Unique identifier for the action (e.g. "login:ip:127.0.0.1")
32
+ limit: Max allowed attempts per window
33
+ window: Time window in seconds
34
+
35
+ Raises:
36
+ RateLimitExceeded: If the limit is exceeded.
37
+ """
38
+ # Fixed Window: bucket is defined by floor(time / window)
39
+ now = int(time.time())
40
+ bucket = now // window
41
+
42
+ # Key includes the bucket so it auto-resets when window changes
43
+ # e.g. "rate_limit:login:ip:127.0.0.1:167890"
44
+ rate_key = f"rate_limit:{key}:{bucket}"
45
+
46
+ # Increment
47
+ # We set TTL to window * 2 to ensure we don't clutter DB, but enough to cover the window width.
48
+ # Actually window size is enough + buffer.
49
+ count = await self._coordination_service.increment(rate_key, 1, ttl=window + 60)
50
+
51
+ if count > limit:
52
+ raise RateLimitExceeded("Rate limit exceeded. Try again later.")
@@ -0,0 +1,71 @@
1
+ import time
2
+ from typing import Optional
3
+
4
+ from sqlalchemy import select, text
5
+
6
+ from supernote.models.base import ProcessingStatus
7
+ from supernote.server.db.models.note_processing import SystemTaskDO
8
+ from supernote.server.db.session import DatabaseSessionManager
9
+ from supernote.server.utils.unique_id import next_id
10
+
11
+
12
+ async def get_task(
13
+ session_manager: DatabaseSessionManager,
14
+ file_id: int,
15
+ task_type: str,
16
+ key: str,
17
+ ) -> Optional[SystemTaskDO]:
18
+ """Retrieve a SystemTaskDO by file_id, task_type, and key."""
19
+ async with session_manager.session() as session:
20
+ return (
21
+ (
22
+ await session.execute(
23
+ select(SystemTaskDO)
24
+ .where(SystemTaskDO.file_id == file_id)
25
+ .where(SystemTaskDO.task_type == task_type)
26
+ .where(SystemTaskDO.key == key)
27
+ )
28
+ )
29
+ .scalars()
30
+ .first()
31
+ )
32
+
33
+
34
+ async def update_task_status(
35
+ session_manager: DatabaseSessionManager,
36
+ file_id: int,
37
+ task_type: str,
38
+ key: str,
39
+ status: ProcessingStatus,
40
+ error: Optional[str] = None,
41
+ ) -> None:
42
+ """Create or update a SystemTaskDO status atomically."""
43
+ async with session_manager.session() as session:
44
+ now = int(time.time() * 1000)
45
+ # Using native SQL UPSERT because SQLite's ON CONFLICT
46
+ # is very robust for our needs and avoids SELECT-then-UPDATE races.
47
+ # We must provide 'id' and 'retry_count' manually for the INSERT part.
48
+ sql = text(
49
+ """
50
+ INSERT INTO f_system_task (id, file_id, task_type, key, status, last_error, retry_count, create_time, update_time)
51
+ VALUES (:id, :file_id, :task_type, :key, :status, :error, 0, :now, :now)
52
+ ON CONFLICT(file_id, task_type, key) DO UPDATE SET
53
+ status = excluded.status,
54
+ last_error = excluded.last_error,
55
+ update_time = excluded.update_time
56
+ """
57
+ )
58
+
59
+ await session.execute(
60
+ sql,
61
+ {
62
+ "id": next_id(),
63
+ "file_id": file_id,
64
+ "task_type": task_type,
65
+ "key": key,
66
+ "status": status,
67
+ "error": error,
68
+ "now": now,
69
+ },
70
+ )
71
+ await session.commit()
@@ -0,0 +1,48 @@
1
+ """Module for generating unique ids."""
2
+
3
+ import random
4
+ import threading
5
+ import time
6
+
7
+ # Global lock for thread safety
8
+ _lock = threading.Lock()
9
+ _last_timestamp = -1
10
+ _sequence = 0
11
+
12
+
13
+ def next_id() -> int:
14
+ """Generate a unique, time-ordered 64-bit integer ID.
15
+
16
+ Format: [Timestamp (41 bits)] [Random/Sequence (22 bits)]
17
+
18
+ This is a simplified version for a single-node server.
19
+ """
20
+ global _last_timestamp, _sequence
21
+
22
+ with _lock:
23
+ timestamp = int(time.time() * 1000)
24
+
25
+ if timestamp < _last_timestamp:
26
+ # Clock moved backwards, just wait a bit or raise
27
+ raise Exception("Clock moved backwards")
28
+
29
+ if timestamp == _last_timestamp:
30
+ _sequence = (_sequence + 1) & 0x3FFFFF # 22 bits
31
+ if _sequence == 0:
32
+ # Overflow in same ms, wait for next ms
33
+ while timestamp <= _last_timestamp:
34
+ timestamp = int(time.time() * 1000)
35
+ else:
36
+ _sequence = random.randint(
37
+ 0, 1023
38
+ ) # Start with random offset to minimize collisions on restart
39
+ _last_timestamp = timestamp
40
+
41
+ # 2021-01-01 Epoch
42
+ epoch_offset = 1609459200000
43
+
44
+ # Shift to make room for 22 bits of randomness/sequence
45
+ # 41 bits (time) + 22 bits (seq) = 63 bits (Fits in Signed BigInt)
46
+ id_val = ((timestamp - epoch_offset) << 22) | _sequence
47
+
48
+ return id_val