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.
- supernote/__init__.py +28 -0
- supernote/alembic/__init__.py +0 -0
- supernote/alembic/env.py +121 -0
- supernote/alembic/script.py.mako +28 -0
- supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
- supernote/alembic.ini +149 -0
- supernote/cli/__init__.py +1 -0
- supernote/cli/admin.py +178 -0
- supernote/cli/client.py +434 -0
- supernote/cli/main.py +61 -0
- supernote/cli/notebook.py +313 -0
- supernote/cli/server.py +89 -0
- supernote/client/__init__.py +28 -0
- supernote/client/admin.py +76 -0
- supernote/client/api.py +135 -0
- supernote/client/api_model.py +231 -0
- supernote/client/auth.py +84 -0
- supernote/client/client.py +365 -0
- supernote/client/device.py +361 -0
- supernote/client/exceptions.py +33 -0
- supernote/client/extended.py +20 -0
- supernote/client/hashing.py +50 -0
- supernote/client/login_client.py +154 -0
- supernote/client/schedule.py +149 -0
- supernote/client/summary.py +156 -0
- supernote/client/web.py +213 -0
- supernote/models/__init__.py +14 -0
- supernote/models/auth.py +380 -0
- supernote/models/base.py +266 -0
- supernote/models/equipment.py +276 -0
- supernote/models/extended.py +124 -0
- supernote/models/file_common.py +95 -0
- supernote/models/file_device.py +571 -0
- supernote/models/file_web.py +602 -0
- supernote/models/schedule.py +663 -0
- supernote/models/summary.py +830 -0
- supernote/models/system.py +431 -0
- supernote/models/user.py +638 -0
- supernote/notebook/__init__.py +67 -0
- supernote/notebook/color.py +93 -0
- supernote/notebook/converter.py +560 -0
- supernote/notebook/decoder.py +398 -0
- supernote/notebook/exceptions.py +43 -0
- supernote/notebook/fileformat.py +463 -0
- supernote/notebook/manipulator.py +420 -0
- supernote/notebook/parser.py +737 -0
- supernote/notebook/utils.py +49 -0
- supernote/py.typed +0 -0
- supernote/server/__init__.py +12 -0
- supernote/server/app.py +428 -0
- supernote/server/config.py +265 -0
- supernote/server/constants.py +26 -0
- supernote/server/db/__init__.py +9 -0
- supernote/server/db/base.py +8 -0
- supernote/server/db/migrations.py +44 -0
- supernote/server/db/models/__init__.py +23 -0
- supernote/server/db/models/device.py +17 -0
- supernote/server/db/models/file.py +107 -0
- supernote/server/db/models/kv.py +17 -0
- supernote/server/db/models/login_record.py +22 -0
- supernote/server/db/models/note_processing.py +100 -0
- supernote/server/db/models/schedule.py +83 -0
- supernote/server/db/models/summary.py +122 -0
- supernote/server/db/models/user.py +30 -0
- supernote/server/db/session.py +88 -0
- supernote/server/events.py +63 -0
- supernote/server/exceptions.py +153 -0
- supernote/server/mcp/__init__.py +1 -0
- supernote/server/mcp/models.py +109 -0
- supernote/server/mcp/server.py +176 -0
- supernote/server/resources/__init__.py +0 -0
- supernote/server/resources/prompts/__init__.py +0 -0
- supernote/server/resources/prompts/ocr/common/context.md +19 -0
- supernote/server/resources/prompts/ocr/common/legend.md +11 -0
- supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
- supernote/server/resources/prompts/ocr/default/system.md +14 -0
- supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
- supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
- supernote/server/resources/prompts/summary/common/instruction.md +8 -0
- supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
- supernote/server/resources/prompts/summary/default/prompt.md +4 -0
- supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
- supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
- supernote/server/routes/admin.py +109 -0
- supernote/server/routes/auth.py +337 -0
- supernote/server/routes/decorators.py +13 -0
- supernote/server/routes/extended.py +139 -0
- supernote/server/routes/file_device.py +593 -0
- supernote/server/routes/file_web.py +597 -0
- supernote/server/routes/oss.py +317 -0
- supernote/server/routes/schedule.py +223 -0
- supernote/server/routes/summary.py +390 -0
- supernote/server/routes/system.py +57 -0
- supernote/server/services/__init__.py +15 -0
- supernote/server/services/blob.py +214 -0
- supernote/server/services/coordination.py +171 -0
- supernote/server/services/file.py +970 -0
- supernote/server/services/gemini.py +66 -0
- supernote/server/services/integrity.py +112 -0
- supernote/server/services/processor.py +331 -0
- supernote/server/services/processor_modules/__init__.py +145 -0
- supernote/server/services/processor_modules/gemini_embedding.py +112 -0
- supernote/server/services/processor_modules/gemini_ocr.py +149 -0
- supernote/server/services/processor_modules/page_hashing.py +220 -0
- supernote/server/services/processor_modules/png_conversion.py +107 -0
- supernote/server/services/processor_modules/summary.py +302 -0
- supernote/server/services/prompt_loader.py +133 -0
- supernote/server/services/schedule.py +161 -0
- supernote/server/services/search.py +250 -0
- supernote/server/services/summary.py +419 -0
- supernote/server/services/user.py +480 -0
- supernote/server/services/vfs.py +476 -0
- supernote/server/static/favicon.ico +0 -0
- supernote/server/static/index.html +186 -0
- supernote/server/static/js/api/client.js +463 -0
- supernote/server/static/js/components/FileCard.js +55 -0
- supernote/server/static/js/components/FileViewer.js +137 -0
- supernote/server/static/js/components/LoginCard.js +64 -0
- supernote/server/static/js/components/MoveModal.js +74 -0
- supernote/server/static/js/components/RenameModal.js +39 -0
- supernote/server/static/js/components/SummaryPanel.js +101 -0
- supernote/server/static/js/components/SystemPanel.js +158 -0
- supernote/server/static/js/composables/useFileSystem.js +116 -0
- supernote/server/static/js/main.js +241 -0
- supernote/server/static/style.css +14 -0
- supernote/server/utils/hashing.py +18 -0
- supernote/server/utils/note_content.py +76 -0
- supernote/server/utils/paths.py +58 -0
- supernote/server/utils/rate_limit.py +52 -0
- supernote/server/utils/tasks.py +71 -0
- supernote/server/utils/unique_id.py +48 -0
- supernote/server/utils/url_signer.py +218 -0
- supernote-0.13.6.dist-info/METADATA +183 -0
- supernote-0.13.6.dist-info/RECORD +138 -0
- supernote-0.13.6.dist-info/WHEEL +5 -0
- supernote-0.13.6.dist-info/entry_points.txt +3 -0
- supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
- 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,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
|