pensiev 0.25.5__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.
- memos/__init__.py +6 -0
- memos/cmds/__init__.py +0 -0
- memos/cmds/library.py +1289 -0
- memos/cmds/plugin.py +96 -0
- memos/commands.py +865 -0
- memos/config.py +225 -0
- memos/crud.py +605 -0
- memos/databases/__init__.py +0 -0
- memos/databases/initializers.py +481 -0
- memos/dataset_extractor_for_florence.py +165 -0
- memos/dataset_extractor_for_internvl2.py +192 -0
- memos/default_config.yaml +88 -0
- memos/embedding.py +129 -0
- memos/frame_extractor.py +53 -0
- memos/logging_config.py +35 -0
- memos/main.py +104 -0
- memos/migrations/alembic/README +1 -0
- memos/migrations/alembic/__pycache__/env.cpython-310.pyc +0 -0
- memos/migrations/alembic/env.py +108 -0
- memos/migrations/alembic/script.py.mako +30 -0
- memos/migrations/alembic/versions/00904ac8c6fc_add_indexes_to_entitymodel.py +63 -0
- memos/migrations/alembic/versions/04acdaf75664_add_indices_to_entitytags_and_metadata.py +86 -0
- memos/migrations/alembic/versions/12504c5b1d3c_add_extra_columns_for_embedding.py +67 -0
- memos/migrations/alembic/versions/31a1ad0e10b3_add_entity_plugin_status.py +71 -0
- memos/migrations/alembic/versions/__pycache__/00904ac8c6fc_add_indexes_to_entitymodel.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/04acdaf75664_add_indices_to_entitytags_and_metadata.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/12504c5b1d3c_add_extra_columns_for_embedding.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/20f5ecab014d_add_entity_plugin_status.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/31a1ad0e10b3_add_entity_plugin_status.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/4fcb062c5128_add_extra_columns_for_embedding.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/d10c55fbb7d2_add_index_for_entity_file_type_group_.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/__pycache__/f8f158182416_add_active_app_index.cpython-310.pyc +0 -0
- memos/migrations/alembic/versions/d10c55fbb7d2_add_index_for_entity_file_type_group_.py +44 -0
- memos/migrations/alembic/versions/f8f158182416_add_active_app_index.py +75 -0
- memos/migrations/alembic.ini +116 -0
- memos/migrations.py +19 -0
- memos/models.py +199 -0
- memos/plugins/__init__.py +0 -0
- memos/plugins/ocr/__init__.py +0 -0
- memos/plugins/ocr/main.py +251 -0
- memos/plugins/ocr/models/ch_PP-OCRv4_det_infer.onnx +0 -0
- memos/plugins/ocr/models/ch_PP-OCRv4_rec_infer.onnx +0 -0
- memos/plugins/ocr/models/ch_ppocr_mobile_v2.0_cls_train.onnx +0 -0
- memos/plugins/ocr/ppocr-gpu.yaml +43 -0
- memos/plugins/ocr/ppocr.yaml +44 -0
- memos/plugins/ocr/server.py +227 -0
- memos/plugins/ocr/temp_ppocr.yaml +42 -0
- memos/plugins/vlm/__init__.py +0 -0
- memos/plugins/vlm/main.py +251 -0
- memos/prepare_dataset.py +107 -0
- memos/process_webp.py +55 -0
- memos/read_metadata.py +32 -0
- memos/record.py +358 -0
- memos/schemas.py +289 -0
- memos/search.py +1198 -0
- memos/server.py +883 -0
- memos/shotsum.py +105 -0
- memos/shotsum_with_ocr.py +145 -0
- memos/simple_tokenizer/dict/README.md +31 -0
- memos/simple_tokenizer/dict/hmm_model.utf8 +34 -0
- memos/simple_tokenizer/dict/idf.utf8 +258826 -0
- memos/simple_tokenizer/dict/jieba.dict.utf8 +348982 -0
- memos/simple_tokenizer/dict/pos_dict/char_state_tab.utf8 +6653 -0
- memos/simple_tokenizer/dict/pos_dict/prob_emit.utf8 +166 -0
- memos/simple_tokenizer/dict/pos_dict/prob_start.utf8 +259 -0
- memos/simple_tokenizer/dict/pos_dict/prob_trans.utf8 +5222 -0
- memos/simple_tokenizer/dict/stop_words.utf8 +1534 -0
- memos/simple_tokenizer/dict/user.dict.utf8 +4 -0
- memos/simple_tokenizer/linux/libsimple.so +0 -0
- memos/simple_tokenizer/macos/libsimple.dylib +0 -0
- memos/simple_tokenizer/windows/simple.dll +0 -0
- memos/static/_app/immutable/assets/0.e250c031.css +1 -0
- memos/static/_app/immutable/assets/_layout.e7937cfe.css +1 -0
- memos/static/_app/immutable/chunks/index.5c08976b.js +1 -0
- memos/static/_app/immutable/chunks/index.60ee613b.js +4 -0
- memos/static/_app/immutable/chunks/runtime.a7926cf6.js +5 -0
- memos/static/_app/immutable/chunks/scheduler.5c1cff6e.js +1 -0
- memos/static/_app/immutable/chunks/singletons.583bdf4e.js +1 -0
- memos/static/_app/immutable/entry/app.666c1643.js +1 -0
- memos/static/_app/immutable/entry/start.aed5c701.js +3 -0
- memos/static/_app/immutable/nodes/0.5862ea38.js +7 -0
- memos/static/_app/immutable/nodes/1.35378a5e.js +1 -0
- memos/static/_app/immutable/nodes/2.1ccf9ea5.js +81 -0
- memos/static/_app/version.json +1 -0
- memos/static/app.html +36 -0
- memos/static/favicon.png +0 -0
- memos/static/logos/memos_logo_1024.png +0 -0
- memos/static/logos/memos_logo_1024@2x.png +0 -0
- memos/static/logos/memos_logo_128.png +0 -0
- memos/static/logos/memos_logo_128@2x.png +0 -0
- memos/static/logos/memos_logo_16.png +0 -0
- memos/static/logos/memos_logo_16@2x.png +0 -0
- memos/static/logos/memos_logo_256.png +0 -0
- memos/static/logos/memos_logo_256@2x.png +0 -0
- memos/static/logos/memos_logo_32.png +0 -0
- memos/static/logos/memos_logo_32@2x.png +0 -0
- memos/static/logos/memos_logo_512.png +0 -0
- memos/static/logos/memos_logo_512@2x.png +0 -0
- memos/static/logos/memos_logo_64.png +0 -0
- memos/static/logos/memos_logo_64@2x.png +0 -0
- memos/test_server.py +802 -0
- memos/utils.py +49 -0
- memos_ml_backends/florence2_server.py +176 -0
- memos_ml_backends/qwen2vl_server.py +182 -0
- memos_ml_backends/schemas.py +48 -0
- pensiev-0.25.5.dist-info/LICENSE +201 -0
- pensiev-0.25.5.dist-info/METADATA +541 -0
- pensiev-0.25.5.dist-info/RECORD +111 -0
- pensiev-0.25.5.dist-info/WHEEL +5 -0
- pensiev-0.25.5.dist-info/entry_points.txt +2 -0
- pensiev-0.25.5.dist-info/top_level.txt +2 -0
memos/commands.py
ADDED
@@ -0,0 +1,865 @@
|
|
1
|
+
# Standard library imports
|
2
|
+
import os
|
3
|
+
import time
|
4
|
+
import logging
|
5
|
+
from pathlib import Path
|
6
|
+
from datetime import datetime, timedelta
|
7
|
+
from typing import List
|
8
|
+
|
9
|
+
# Third-party imports
|
10
|
+
import httpx
|
11
|
+
import typer
|
12
|
+
|
13
|
+
# Local imports
|
14
|
+
from .config import settings, display_config
|
15
|
+
|
16
|
+
import sys
|
17
|
+
import subprocess
|
18
|
+
import platform
|
19
|
+
|
20
|
+
from .cmds.plugin import plugin_app
|
21
|
+
from .cmds.library import lib_app
|
22
|
+
|
23
|
+
import psutil
|
24
|
+
import signal
|
25
|
+
from tabulate import tabulate
|
26
|
+
|
27
|
+
try:
|
28
|
+
from memos import __version__
|
29
|
+
except ImportError:
|
30
|
+
__version__ = "Unknown"
|
31
|
+
|
32
|
+
|
33
|
+
app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
|
34
|
+
|
35
|
+
BASE_URL = settings.server_endpoint
|
36
|
+
|
37
|
+
# Configure logging
|
38
|
+
logging.basicConfig(
|
39
|
+
level=logging.WARNING, # Set the logging level to WARNING or higher
|
40
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
41
|
+
)
|
42
|
+
|
43
|
+
# Optionally, you can set the logging level for specific libraries
|
44
|
+
logging.getLogger("httpx").setLevel(logging.ERROR)
|
45
|
+
logging.getLogger("typer").setLevel(logging.ERROR)
|
46
|
+
|
47
|
+
|
48
|
+
def check_server_health():
|
49
|
+
"""Check if the server is running and healthy."""
|
50
|
+
try:
|
51
|
+
response = httpx.get(f"{BASE_URL}/health", timeout=5)
|
52
|
+
return response.status_code == 200
|
53
|
+
except httpx.RequestException:
|
54
|
+
return False
|
55
|
+
|
56
|
+
|
57
|
+
def callback(ctx: typer.Context):
|
58
|
+
"""Callback to check server health before running any command."""
|
59
|
+
# List of commands that require the server to be running
|
60
|
+
server_dependent_commands = [
|
61
|
+
"scan",
|
62
|
+
"reindex",
|
63
|
+
"watch",
|
64
|
+
"ls",
|
65
|
+
"create",
|
66
|
+
"add-folder",
|
67
|
+
"show",
|
68
|
+
"sync",
|
69
|
+
"bind",
|
70
|
+
"unbind",
|
71
|
+
]
|
72
|
+
|
73
|
+
if ctx.invoked_subcommand in server_dependent_commands:
|
74
|
+
if not check_server_health():
|
75
|
+
typer.echo("Error: Server is not running. Please start the server first.")
|
76
|
+
raise typer.Exit(code=1)
|
77
|
+
|
78
|
+
|
79
|
+
app.add_typer(plugin_app, name="plugin")
|
80
|
+
app.add_typer(lib_app, name="lib", callback=callback)
|
81
|
+
|
82
|
+
|
83
|
+
@app.command()
|
84
|
+
def serve():
|
85
|
+
"""Run the server after initializing if necessary."""
|
86
|
+
from .databases.initializers import init_database
|
87
|
+
from .migrations import run_migrations
|
88
|
+
|
89
|
+
db_success = init_database(settings)
|
90
|
+
if db_success:
|
91
|
+
# Run any pending migrations
|
92
|
+
run_migrations()
|
93
|
+
|
94
|
+
from .server import run_server
|
95
|
+
run_server()
|
96
|
+
else:
|
97
|
+
print("Server initialization failed. Unable to start the server.")
|
98
|
+
|
99
|
+
|
100
|
+
@app.command()
|
101
|
+
def init():
|
102
|
+
"""Initialize the database."""
|
103
|
+
from .databases.initializers import init_database
|
104
|
+
from .migrations import run_migrations
|
105
|
+
|
106
|
+
db_success = init_database(settings)
|
107
|
+
if db_success:
|
108
|
+
run_migrations()
|
109
|
+
print("Initialization completed successfully.")
|
110
|
+
else:
|
111
|
+
print("Initialization failed. Please check the error messages above.")
|
112
|
+
|
113
|
+
|
114
|
+
def get_or_create_default_library():
|
115
|
+
"""
|
116
|
+
Get the default library or create it if it doesn't exist.
|
117
|
+
Ensure the library has at least one folder.
|
118
|
+
"""
|
119
|
+
from .cmds.plugin import bind
|
120
|
+
|
121
|
+
response = httpx.get(f"{BASE_URL}/libraries")
|
122
|
+
if response.status_code != 200:
|
123
|
+
print(f"Failed to retrieve libraries: {response.status_code} - {response.text}")
|
124
|
+
return None
|
125
|
+
|
126
|
+
libraries = response.json()
|
127
|
+
default_library = next(
|
128
|
+
(lib for lib in libraries if lib["name"] == settings.default_library), None
|
129
|
+
)
|
130
|
+
|
131
|
+
if not default_library:
|
132
|
+
# Create the default library if it doesn't exist
|
133
|
+
response = httpx.post(
|
134
|
+
f"{BASE_URL}/libraries",
|
135
|
+
json={"name": settings.default_library, "folders": []},
|
136
|
+
)
|
137
|
+
if response.status_code != 200:
|
138
|
+
print(
|
139
|
+
f"Failed to create default library: {response.status_code} - {response.text}"
|
140
|
+
)
|
141
|
+
return None
|
142
|
+
default_library = response.json()
|
143
|
+
|
144
|
+
for plugin in settings.default_plugins:
|
145
|
+
bind(default_library["id"], plugin)
|
146
|
+
|
147
|
+
# Check if the library is empty
|
148
|
+
if not default_library["folders"]:
|
149
|
+
# Add the screenshots directory to the library
|
150
|
+
screenshots_dir = Path(settings.resolved_screenshots_dir).resolve()
|
151
|
+
folder = {
|
152
|
+
"path": str(screenshots_dir),
|
153
|
+
"last_modified_at": datetime.fromtimestamp(
|
154
|
+
screenshots_dir.stat().st_mtime
|
155
|
+
).isoformat(),
|
156
|
+
}
|
157
|
+
response = httpx.post(
|
158
|
+
f"{BASE_URL}/libraries/{default_library['id']}/folders",
|
159
|
+
json={"folders": [folder]},
|
160
|
+
)
|
161
|
+
if response.status_code != 200:
|
162
|
+
print(
|
163
|
+
f"Failed to add screenshots directory: {response.status_code} - {response.text}"
|
164
|
+
)
|
165
|
+
return None
|
166
|
+
print(f"Added screenshots directory: {screenshots_dir}")
|
167
|
+
|
168
|
+
return default_library
|
169
|
+
|
170
|
+
|
171
|
+
@app.command("scan")
|
172
|
+
def scan_default_library(
|
173
|
+
force: bool = typer.Option(False, "--force", help="Force update all indexes"),
|
174
|
+
path: str = typer.Argument(None, help="Path to scan within the library"),
|
175
|
+
plugins: List[int] = typer.Option(None, "--plugin", "-p"),
|
176
|
+
folders: List[int] = typer.Option(None, "--folder", "-f"),
|
177
|
+
batch_size: int = typer.Option(
|
178
|
+
1, "--batch-size", "-bs", help="Batch size for processing files"
|
179
|
+
),
|
180
|
+
):
|
181
|
+
"""
|
182
|
+
Scan the screenshots directory and add it to the library if empty.
|
183
|
+
"""
|
184
|
+
from .cmds.library import scan
|
185
|
+
|
186
|
+
default_library = get_or_create_default_library()
|
187
|
+
if not default_library:
|
188
|
+
return
|
189
|
+
|
190
|
+
print(f"Scanning library: {default_library['name']}")
|
191
|
+
scan(
|
192
|
+
default_library["id"],
|
193
|
+
path=path,
|
194
|
+
plugins=plugins,
|
195
|
+
folders=folders,
|
196
|
+
force=force,
|
197
|
+
batch_size=batch_size,
|
198
|
+
)
|
199
|
+
|
200
|
+
|
201
|
+
@app.command("reindex")
|
202
|
+
def reindex_default_library(
|
203
|
+
force: bool = typer.Option(
|
204
|
+
False, "--force", help="Force recreate FTS and vector tables before reindexing"
|
205
|
+
),
|
206
|
+
batch_size: int = typer.Option(
|
207
|
+
1, "--batch-size", "-bs", help="Batch size for processing files"
|
208
|
+
),
|
209
|
+
):
|
210
|
+
"""
|
211
|
+
Reindex the default library for memos.
|
212
|
+
"""
|
213
|
+
from .cmds.library import reindex
|
214
|
+
|
215
|
+
# Get the default library
|
216
|
+
response = httpx.get(f"{BASE_URL}/libraries")
|
217
|
+
if response.status_code != 200:
|
218
|
+
print(f"Failed to retrieve libraries: {response.status_code} - {response.text}")
|
219
|
+
return
|
220
|
+
|
221
|
+
libraries = response.json()
|
222
|
+
default_library = next(
|
223
|
+
(lib for lib in libraries if lib["name"] == settings.default_library), None
|
224
|
+
)
|
225
|
+
|
226
|
+
if not default_library:
|
227
|
+
print("Default library does not exist.")
|
228
|
+
return
|
229
|
+
|
230
|
+
# Reindex the library
|
231
|
+
print(f"Reindexing library: {default_library['name']}")
|
232
|
+
reindex(default_library["id"], force=force, folders=None, batch_size=batch_size)
|
233
|
+
|
234
|
+
|
235
|
+
@app.command("record")
|
236
|
+
def record(
|
237
|
+
threshold: int = typer.Option(4, help="Threshold for image similarity"),
|
238
|
+
base_dir: str = typer.Option(None, help="Base directory for screenshots"),
|
239
|
+
once: bool = typer.Option(False, help="Run once and exit"),
|
240
|
+
):
|
241
|
+
"""
|
242
|
+
Record screenshots of the screen.
|
243
|
+
"""
|
244
|
+
from .record import (
|
245
|
+
run_screen_recorder_once,
|
246
|
+
run_screen_recorder,
|
247
|
+
load_previous_hashes,
|
248
|
+
)
|
249
|
+
|
250
|
+
base_dir = (
|
251
|
+
os.path.expanduser(base_dir) if base_dir else settings.resolved_screenshots_dir
|
252
|
+
)
|
253
|
+
previous_hashes = load_previous_hashes(base_dir)
|
254
|
+
|
255
|
+
if once:
|
256
|
+
run_screen_recorder_once(threshold, base_dir, previous_hashes)
|
257
|
+
else:
|
258
|
+
# Log the record interval
|
259
|
+
logging.info(f"Record interval set to {settings.record_interval} seconds.")
|
260
|
+
while True:
|
261
|
+
try:
|
262
|
+
run_screen_recorder(threshold, base_dir, previous_hashes)
|
263
|
+
except Exception as e:
|
264
|
+
logging.error(
|
265
|
+
f"Critical error occurred, program will restart in 10 seconds: {str(e)}"
|
266
|
+
)
|
267
|
+
time.sleep(10)
|
268
|
+
|
269
|
+
|
270
|
+
@app.command("watch")
|
271
|
+
def watch_default_library(
|
272
|
+
rate_window_size: int = typer.Option(
|
273
|
+
settings.watch.rate_window_size,
|
274
|
+
"--rate-window",
|
275
|
+
"-rw",
|
276
|
+
help="Window size for rate calculation",
|
277
|
+
),
|
278
|
+
sparsity_factor: float = typer.Option(
|
279
|
+
settings.watch.sparsity_factor,
|
280
|
+
"--sparsity-factor",
|
281
|
+
"-sf",
|
282
|
+
help="Sparsity factor for file processing",
|
283
|
+
),
|
284
|
+
processing_interval: int = typer.Option(
|
285
|
+
settings.watch.processing_interval,
|
286
|
+
"--processing-interval",
|
287
|
+
"-pi",
|
288
|
+
help="Processing interval for file processing",
|
289
|
+
),
|
290
|
+
verbose: bool = typer.Option(
|
291
|
+
False, "--verbose", "-v", help="Enable verbose logging"
|
292
|
+
),
|
293
|
+
):
|
294
|
+
"""
|
295
|
+
Watch the default library for file changes and sync automatically.
|
296
|
+
"""
|
297
|
+
typer.echo(f"Watch settings:")
|
298
|
+
typer.echo(f" rate_window_size: {rate_window_size}")
|
299
|
+
typer.echo(f" sparsity_factor: {sparsity_factor}")
|
300
|
+
typer.echo(f" processing_interval: {processing_interval}")
|
301
|
+
|
302
|
+
from .cmds.library import watch
|
303
|
+
|
304
|
+
default_library = get_or_create_default_library()
|
305
|
+
if not default_library:
|
306
|
+
return
|
307
|
+
|
308
|
+
watch(
|
309
|
+
default_library["id"],
|
310
|
+
folders=None,
|
311
|
+
rate_window_size=rate_window_size,
|
312
|
+
sparsity_factor=sparsity_factor,
|
313
|
+
processing_interval=processing_interval,
|
314
|
+
verbose=verbose,
|
315
|
+
)
|
316
|
+
|
317
|
+
|
318
|
+
def get_python_path():
|
319
|
+
return sys.executable
|
320
|
+
|
321
|
+
|
322
|
+
def generate_windows_bat():
|
323
|
+
memos_dir = settings.resolved_base_dir
|
324
|
+
python_path = get_python_path()
|
325
|
+
pythonw_path = python_path.replace("python.exe", "pythonw.exe")
|
326
|
+
conda_prefix = os.environ.get("CONDA_PREFIX")
|
327
|
+
log_dir = memos_dir / "logs"
|
328
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
329
|
+
|
330
|
+
if conda_prefix:
|
331
|
+
# If we're in a Conda environment
|
332
|
+
activate_path = os.path.join(conda_prefix, "Scripts", "activate.bat")
|
333
|
+
content = f"""@echo off
|
334
|
+
call "{activate_path}"
|
335
|
+
start /B "" "{pythonw_path}" -m memos.commands record > "{log_dir / 'record.log'}" 2>&1
|
336
|
+
start /B "" "{pythonw_path}" -m memos.commands serve > "{log_dir / 'serve.log'}" 2>&1
|
337
|
+
timeout /t 15 /nobreak >nul
|
338
|
+
start /B "" "{pythonw_path}" -m memos.commands watch > "{log_dir / 'watch.log'}" 2>&1
|
339
|
+
"""
|
340
|
+
else:
|
341
|
+
# If we're not in a Conda environment
|
342
|
+
content = f"""@echo off
|
343
|
+
start /B "" "{pythonw_path}" -m memos.commands record > "{log_dir / 'record.log'}" 2>&1
|
344
|
+
start /B "" "{pythonw_path}" -m memos.commands serve > "{log_dir / 'serve.log'}" 2>&1
|
345
|
+
timeout /t 15 /nobreak >nul
|
346
|
+
start /B "" "{pythonw_path}" -m memos.commands watch > "{log_dir / 'watch.log'}" 2>&1
|
347
|
+
"""
|
348
|
+
|
349
|
+
bat_path = memos_dir / "launch.bat"
|
350
|
+
with open(bat_path, "w") as f:
|
351
|
+
f.write(content)
|
352
|
+
return bat_path
|
353
|
+
|
354
|
+
|
355
|
+
def generate_launch_sh():
|
356
|
+
memos_dir = settings.resolved_base_dir
|
357
|
+
python_path = get_python_path()
|
358
|
+
content = f"""#!/bin/bash
|
359
|
+
# activate current python environment
|
360
|
+
if [ -f "$(dirname "$python_path")/activate" ]; then
|
361
|
+
source "$(dirname "$python_path")/activate"
|
362
|
+
fi
|
363
|
+
|
364
|
+
# run memos record
|
365
|
+
{python_path} -m memos.commands record &
|
366
|
+
|
367
|
+
# run memos serve
|
368
|
+
{python_path} -m memos.commands serve &
|
369
|
+
|
370
|
+
# wait for 15 seconds before starting memos watch
|
371
|
+
sleep 15
|
372
|
+
|
373
|
+
# run memos watch
|
374
|
+
{python_path} -m memos.commands watch &
|
375
|
+
|
376
|
+
# wait for all background processes
|
377
|
+
wait
|
378
|
+
"""
|
379
|
+
launch_sh_path = memos_dir / "launch.sh"
|
380
|
+
with open(launch_sh_path, "w") as f:
|
381
|
+
f.write(content)
|
382
|
+
launch_sh_path.chmod(0o755)
|
383
|
+
|
384
|
+
|
385
|
+
def setup_windows_autostart(bat_path):
|
386
|
+
import win32com.client
|
387
|
+
|
388
|
+
startup_folder = (
|
389
|
+
Path(os.getenv("APPDATA")) / r"Microsoft\Windows\Start Menu\Programs\Startup"
|
390
|
+
)
|
391
|
+
shortcut_path = startup_folder / "Memos.lnk"
|
392
|
+
|
393
|
+
shell = win32com.client.Dispatch("WScript.Shell")
|
394
|
+
shortcut = shell.CreateShortCut(str(shortcut_path))
|
395
|
+
shortcut.Targetpath = str(bat_path)
|
396
|
+
shortcut.WorkingDirectory = str(bat_path.parent)
|
397
|
+
shortcut.WindowStyle = 7 # Minimized
|
398
|
+
shortcut.save()
|
399
|
+
|
400
|
+
|
401
|
+
def generate_plist():
|
402
|
+
memos_dir = settings.resolved_base_dir
|
403
|
+
python_dir = os.path.dirname(get_python_path())
|
404
|
+
log_dir = memos_dir / "logs"
|
405
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
406
|
+
|
407
|
+
plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
408
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
409
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
410
|
+
<plist version="1.0">
|
411
|
+
<dict>
|
412
|
+
<key>Label</key>
|
413
|
+
<string>com.user.memos</string>
|
414
|
+
<key>ProgramArguments</key>
|
415
|
+
<array>
|
416
|
+
<string>/bin/bash</string>
|
417
|
+
<string>{memos_dir}/launch.sh</string>
|
418
|
+
</array>
|
419
|
+
<key>RunAtLoad</key>
|
420
|
+
<true/>
|
421
|
+
<key>StandardOutPath</key>
|
422
|
+
<string>{log_dir}/memos.log</string>
|
423
|
+
<key>StandardErrorPath</key>
|
424
|
+
<string>{log_dir}/memos.err</string>
|
425
|
+
<key>EnvironmentVariables</key>
|
426
|
+
<dict>
|
427
|
+
<key>PATH</key>
|
428
|
+
<string>{python_dir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
429
|
+
</dict>
|
430
|
+
</dict>
|
431
|
+
</plist>
|
432
|
+
"""
|
433
|
+
plist_dir = Path.home() / "Library/LaunchAgents"
|
434
|
+
plist_dir.mkdir(parents=True, exist_ok=True)
|
435
|
+
plist_path = plist_dir / "com.user.memos.plist"
|
436
|
+
with open(plist_path, "w") as f:
|
437
|
+
f.write(plist_content)
|
438
|
+
return plist_path
|
439
|
+
|
440
|
+
|
441
|
+
def is_service_loaded(service_name):
|
442
|
+
try:
|
443
|
+
result = subprocess.run(
|
444
|
+
["launchctl", "list", service_name],
|
445
|
+
capture_output=True,
|
446
|
+
text=True,
|
447
|
+
check=True,
|
448
|
+
)
|
449
|
+
return "0" in result.stdout
|
450
|
+
except subprocess.CalledProcessError:
|
451
|
+
return False
|
452
|
+
|
453
|
+
|
454
|
+
def load_plist(plist_path):
|
455
|
+
service_name = "com.user.memos"
|
456
|
+
|
457
|
+
if is_service_loaded(service_name):
|
458
|
+
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
|
459
|
+
|
460
|
+
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
461
|
+
|
462
|
+
|
463
|
+
def is_macos():
|
464
|
+
return platform.system() == "Darwin"
|
465
|
+
|
466
|
+
|
467
|
+
def is_windows():
|
468
|
+
return platform.system() == "Windows"
|
469
|
+
|
470
|
+
|
471
|
+
def remove_windows_autostart():
|
472
|
+
startup_folder = (
|
473
|
+
Path(os.getenv("APPDATA")) / r"Microsoft\Windows\Start Menu\Programs\Startup"
|
474
|
+
)
|
475
|
+
shortcut_path = startup_folder / "Memos.lnk"
|
476
|
+
|
477
|
+
if shortcut_path.exists():
|
478
|
+
shortcut_path.unlink()
|
479
|
+
return True
|
480
|
+
return False
|
481
|
+
|
482
|
+
|
483
|
+
@app.command()
|
484
|
+
def disable():
|
485
|
+
"""Disable memos from running at startup"""
|
486
|
+
if is_windows():
|
487
|
+
if remove_windows_autostart():
|
488
|
+
typer.echo(
|
489
|
+
"Removed Memos shortcut from startup folder. Memos will no longer run at startup."
|
490
|
+
)
|
491
|
+
else:
|
492
|
+
typer.echo(
|
493
|
+
"Memos shortcut not found in startup folder. Memos is not set to run at startup."
|
494
|
+
)
|
495
|
+
elif is_macos():
|
496
|
+
plist_path = Path.home() / "Library/LaunchAgents/com.user.memos.plist"
|
497
|
+
if plist_path.exists():
|
498
|
+
subprocess.run(["launchctl", "unload", str(plist_path)], check=True)
|
499
|
+
plist_path.unlink()
|
500
|
+
typer.echo(
|
501
|
+
"Unloaded and removed plist file. Memos will no longer run at startup."
|
502
|
+
)
|
503
|
+
else:
|
504
|
+
typer.echo("Plist file does not exist. Memos is not set to run at startup.")
|
505
|
+
else:
|
506
|
+
typer.echo("Unsupported operating system.")
|
507
|
+
|
508
|
+
|
509
|
+
@app.command()
|
510
|
+
def enable():
|
511
|
+
"""Enable memos to run at startup (without starting it immediately)"""
|
512
|
+
if not sys.executable:
|
513
|
+
typer.echo("Error: Unable to detect Python environment.")
|
514
|
+
raise typer.Exit(code=1)
|
515
|
+
|
516
|
+
memos_dir = settings.resolved_base_dir
|
517
|
+
memos_dir.mkdir(parents=True, exist_ok=True)
|
518
|
+
|
519
|
+
if is_windows():
|
520
|
+
bat_path = generate_windows_bat()
|
521
|
+
typer.echo(f"Generated launch script at {bat_path}")
|
522
|
+
setup_windows_autostart(bat_path)
|
523
|
+
typer.echo("Created startup shortcut for Windows.")
|
524
|
+
elif is_macos():
|
525
|
+
launch_sh_path = generate_launch_sh()
|
526
|
+
typer.echo(f"Generated launch script at {launch_sh_path}")
|
527
|
+
plist_path = generate_plist()
|
528
|
+
typer.echo(f"Generated plist file at {plist_path}")
|
529
|
+
load_plist(plist_path)
|
530
|
+
typer.echo(
|
531
|
+
"Loaded plist file. Memos is started and will run at next startup or when 'start' command is used."
|
532
|
+
)
|
533
|
+
else:
|
534
|
+
typer.echo("Unsupported operating system.")
|
535
|
+
|
536
|
+
|
537
|
+
@app.command()
|
538
|
+
def ps():
|
539
|
+
"""Show the status of Memos processes"""
|
540
|
+
services = ["serve", "watch", "record"]
|
541
|
+
table_data = []
|
542
|
+
|
543
|
+
for service in services:
|
544
|
+
processes = [
|
545
|
+
p
|
546
|
+
for p in psutil.process_iter(["pid", "name", "cmdline", "create_time"])
|
547
|
+
if "python" in p.info["name"].lower()
|
548
|
+
and p.info["cmdline"] is not None
|
549
|
+
and "memos.commands" in p.info["cmdline"]
|
550
|
+
and service in p.info["cmdline"]
|
551
|
+
]
|
552
|
+
|
553
|
+
if processes:
|
554
|
+
for process in processes:
|
555
|
+
create_time = datetime.fromtimestamp(
|
556
|
+
process.info["create_time"]
|
557
|
+
).strftime("%Y-%m-%d %H:%M:%S")
|
558
|
+
running_time = str(
|
559
|
+
timedelta(seconds=int(time.time() - process.info["create_time"]))
|
560
|
+
)
|
561
|
+
table_data.append(
|
562
|
+
[service, "Running", process.info["pid"], create_time, running_time]
|
563
|
+
)
|
564
|
+
else:
|
565
|
+
table_data.append([service, "Not Running", "-", "-", "-"])
|
566
|
+
|
567
|
+
headers = ["Name", "Status", "PID", "Started At", "Running For"]
|
568
|
+
typer.echo(tabulate(table_data, headers=headers, tablefmt="plain"))
|
569
|
+
|
570
|
+
|
571
|
+
@app.command()
|
572
|
+
def stop():
|
573
|
+
"""Stop all running Memos processes"""
|
574
|
+
if is_windows():
|
575
|
+
services = ["serve", "watch", "record"]
|
576
|
+
stopped = False
|
577
|
+
|
578
|
+
for service in services:
|
579
|
+
processes = [
|
580
|
+
p
|
581
|
+
for p in psutil.process_iter(["pid", "name", "cmdline"])
|
582
|
+
if "python" in p.info["name"].lower()
|
583
|
+
and p.info["cmdline"] is not None
|
584
|
+
and "memos.commands" in p.info["cmdline"]
|
585
|
+
and service in p.info["cmdline"]
|
586
|
+
]
|
587
|
+
|
588
|
+
for process in processes:
|
589
|
+
try:
|
590
|
+
os.kill(process.info["pid"], signal.SIGTERM)
|
591
|
+
typer.echo(
|
592
|
+
f"Stopped {service} process (PID: {process.info['pid']})"
|
593
|
+
)
|
594
|
+
stopped = True
|
595
|
+
except ProcessLookupError:
|
596
|
+
typer.echo(
|
597
|
+
f"Process {service} (PID: {process.info['pid']}) not found"
|
598
|
+
)
|
599
|
+
except PermissionError:
|
600
|
+
typer.echo(
|
601
|
+
f"Permission denied to stop {service} process (PID: {process.info['pid']})"
|
602
|
+
)
|
603
|
+
|
604
|
+
if not stopped:
|
605
|
+
typer.echo("No running Memos processes found")
|
606
|
+
else:
|
607
|
+
typer.echo("All Memos processes have been stopped")
|
608
|
+
|
609
|
+
elif is_macos():
|
610
|
+
service_name = "com.user.memos"
|
611
|
+
try:
|
612
|
+
subprocess.run(["launchctl", "stop", service_name], check=True)
|
613
|
+
typer.echo("Stopped Memos processes.")
|
614
|
+
except subprocess.CalledProcessError:
|
615
|
+
typer.echo("Failed to stop Memos processes. They may not be running.")
|
616
|
+
|
617
|
+
else:
|
618
|
+
typer.echo("Unsupported operating system.")
|
619
|
+
|
620
|
+
|
621
|
+
@app.command()
|
622
|
+
def start():
|
623
|
+
"""Start all Memos processes"""
|
624
|
+
memos_dir = settings.resolved_base_dir
|
625
|
+
|
626
|
+
if is_windows():
|
627
|
+
bat_path = memos_dir / "launch.bat"
|
628
|
+
if not bat_path.exists():
|
629
|
+
typer.echo("Launch script not found. Please run 'memos enable' first.")
|
630
|
+
return
|
631
|
+
|
632
|
+
try:
|
633
|
+
subprocess.Popen(
|
634
|
+
[str(bat_path)], shell=True, creationflags=subprocess.CREATE_NEW_CONSOLE
|
635
|
+
)
|
636
|
+
typer.echo("Started Memos processes. Check the logs for more information.")
|
637
|
+
except Exception as e:
|
638
|
+
typer.echo(f"Failed to start Memos processes: {str(e)}")
|
639
|
+
|
640
|
+
elif is_macos():
|
641
|
+
service_name = "com.user.memos"
|
642
|
+
subprocess.run(["launchctl", "start", service_name], check=True)
|
643
|
+
typer.echo("Started Memos processes.")
|
644
|
+
else:
|
645
|
+
typer.echo("Unsupported operating system.")
|
646
|
+
|
647
|
+
|
648
|
+
@app.command()
|
649
|
+
def config():
|
650
|
+
"""Show current configuration settings"""
|
651
|
+
display_config()
|
652
|
+
|
653
|
+
|
654
|
+
@app.command("version")
|
655
|
+
def version():
|
656
|
+
"""Output the package version, Python version, and platform information in a single line."""
|
657
|
+
# Get Python version
|
658
|
+
python_version = sys.version.split()[0] # Only get the version number
|
659
|
+
|
660
|
+
# Get platform information
|
661
|
+
system = platform.system()
|
662
|
+
machine = platform.machine()
|
663
|
+
|
664
|
+
# Output all information in a single line
|
665
|
+
typer.echo(
|
666
|
+
f"Package: {__version__}, Python: {python_version}, System: {system.lower()}/{machine.lower()}"
|
667
|
+
)
|
668
|
+
|
669
|
+
|
670
|
+
@app.command("migrate")
|
671
|
+
def migrate_sqlite_to_pg(
|
672
|
+
sqlite_url: str = typer.Option(..., "--sqlite-url", help="SQLite database URL (e.g., sqlite:///path/to/db.sqlite)"),
|
673
|
+
pg_url: str = typer.Option(..., "--pg-url", help="PostgreSQL database URL (e.g., postgresql://user:pass@localhost/dbname)"),
|
674
|
+
batch_size: int = typer.Option(1000, "--batch-size", "-bs", help="Number of records to migrate in each batch"),
|
675
|
+
):
|
676
|
+
"""Migrate data from SQLite to PostgreSQL (excluding FTS and vector tables)"""
|
677
|
+
# Ask for user confirmation
|
678
|
+
typer.echo("WARNING: This will completely erase all data in the PostgreSQL database.")
|
679
|
+
if not typer.confirm("Do you want to continue?"):
|
680
|
+
typer.echo("Migration cancelled.")
|
681
|
+
raise typer.Exit(code=0)
|
682
|
+
|
683
|
+
from sqlalchemy import create_engine, MetaData
|
684
|
+
from sqlalchemy.orm import sessionmaker
|
685
|
+
from sqlalchemy import func
|
686
|
+
from sqlalchemy.sql import text
|
687
|
+
from .models import (
|
688
|
+
LibraryModel, FolderModel, EntityModel, TagModel,
|
689
|
+
EntityTagModel, EntityMetadataModel, PluginModel,
|
690
|
+
LibraryPluginModel, EntityPluginStatusModel
|
691
|
+
)
|
692
|
+
from .databases.initializers import init_database
|
693
|
+
from .migrations import run_migrations
|
694
|
+
|
695
|
+
# Reorder tables to handle foreign key dependencies
|
696
|
+
TABLES_TO_MIGRATE = [
|
697
|
+
# Base tables (no foreign key dependencies)
|
698
|
+
LibraryModel,
|
699
|
+
PluginModel,
|
700
|
+
TagModel,
|
701
|
+
|
702
|
+
# Tables with single foreign key dependencies
|
703
|
+
LibraryPluginModel, # depends on libraries and plugins
|
704
|
+
FolderModel, # depends on libraries
|
705
|
+
EntityModel, # depends on libraries and folders
|
706
|
+
|
707
|
+
# Tables with multiple foreign key dependencies
|
708
|
+
EntityTagModel, # depends on entities and tags
|
709
|
+
EntityMetadataModel, # depends on entities
|
710
|
+
EntityPluginStatusModel, # depends on entities and plugins
|
711
|
+
]
|
712
|
+
|
713
|
+
def copy_instance(obj):
|
714
|
+
"""Create a copy of an object without SQLAlchemy state"""
|
715
|
+
mapper = obj.__mapper__
|
716
|
+
new_instance = obj.__class__()
|
717
|
+
|
718
|
+
for column in mapper.columns:
|
719
|
+
setattr(new_instance, column.key, getattr(obj, column.key))
|
720
|
+
|
721
|
+
return new_instance
|
722
|
+
|
723
|
+
def reset_sequence(session, table):
|
724
|
+
"""Reset PostgreSQL sequence before inserting data"""
|
725
|
+
if not hasattr(table, 'id'):
|
726
|
+
return
|
727
|
+
|
728
|
+
table_name = table.__tablename__
|
729
|
+
seq_name = f"{table_name}_id_seq"
|
730
|
+
|
731
|
+
# Reset sequence to 1
|
732
|
+
session.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1"))
|
733
|
+
session.commit()
|
734
|
+
|
735
|
+
def update_sequence(session, table):
|
736
|
+
"""Update PostgreSQL sequence after data migration"""
|
737
|
+
if not hasattr(table, 'id'):
|
738
|
+
return
|
739
|
+
|
740
|
+
table_name = table.__tablename__
|
741
|
+
seq_name = f"{table_name}_id_seq"
|
742
|
+
|
743
|
+
# Get the maximum ID from the table
|
744
|
+
max_id = session.query(func.max(table.id)).scalar() or 0
|
745
|
+
|
746
|
+
# Set the sequence to the next value after max_id
|
747
|
+
session.execute(text(f"SELECT setval('{seq_name}', {max_id}, true)"))
|
748
|
+
session.commit()
|
749
|
+
|
750
|
+
try:
|
751
|
+
# Create database connections
|
752
|
+
sqlite_engine = create_engine(sqlite_url)
|
753
|
+
pg_engine = create_engine(pg_url)
|
754
|
+
|
755
|
+
# Drop all existing tables in PostgreSQL
|
756
|
+
typer.echo("Dropping existing tables in PostgreSQL...")
|
757
|
+
metadata = MetaData()
|
758
|
+
metadata.reflect(bind=pg_engine)
|
759
|
+
metadata.drop_all(bind=pg_engine)
|
760
|
+
|
761
|
+
# Initialize PostgreSQL database from scratch
|
762
|
+
typer.echo("Initializing PostgreSQL database...")
|
763
|
+
# Temporarily modify settings.database_path instead of database_url
|
764
|
+
original_database_path = settings.database_path
|
765
|
+
settings.database_path = pg_url
|
766
|
+
|
767
|
+
try:
|
768
|
+
db_success = init_database(settings)
|
769
|
+
if not db_success:
|
770
|
+
typer.echo("Failed to initialize PostgreSQL database.")
|
771
|
+
raise typer.Exit(code=1)
|
772
|
+
|
773
|
+
# Run migrations
|
774
|
+
typer.echo("Running migrations...")
|
775
|
+
run_migrations()
|
776
|
+
|
777
|
+
# Create sessions after initialization
|
778
|
+
SQLiteSession = sessionmaker(bind=sqlite_engine)
|
779
|
+
PGSession = sessionmaker(bind=pg_engine)
|
780
|
+
|
781
|
+
sqlite_session = SQLiteSession()
|
782
|
+
pg_session = PGSession()
|
783
|
+
|
784
|
+
|
785
|
+
# Clear any default data created during initialization
|
786
|
+
typer.echo("Clearing initialization data...")
|
787
|
+
for model in reversed(TABLES_TO_MIGRATE):
|
788
|
+
pg_session.query(model).delete()
|
789
|
+
pg_session.commit()
|
790
|
+
|
791
|
+
# Find the longest table name for alignment
|
792
|
+
max_name_length = max(len(model.__tablename__) for model in TABLES_TO_MIGRATE)
|
793
|
+
|
794
|
+
# Migrate each table
|
795
|
+
for model in TABLES_TO_MIGRATE:
|
796
|
+
table_name = model.__tablename__
|
797
|
+
typer.echo(f"Migrating {table_name:<{max_name_length}}...")
|
798
|
+
|
799
|
+
# Reset sequence before migration
|
800
|
+
reset_sequence(pg_session, model)
|
801
|
+
|
802
|
+
# Get total count
|
803
|
+
total_count = sqlite_session.query(model).count()
|
804
|
+
if total_count == 0:
|
805
|
+
typer.echo(f"No data found in {table_name:<{max_name_length}}, skipping...")
|
806
|
+
continue
|
807
|
+
|
808
|
+
# Process in batches
|
809
|
+
processed = 0
|
810
|
+
with typer.progressbar(
|
811
|
+
length=total_count,
|
812
|
+
label=f"Migrating {table_name:<{max_name_length}}"
|
813
|
+
) as progress:
|
814
|
+
while processed < total_count:
|
815
|
+
# Get batch of records from source
|
816
|
+
batch = (
|
817
|
+
sqlite_session.query(model)
|
818
|
+
.offset(processed)
|
819
|
+
.limit(batch_size)
|
820
|
+
.all()
|
821
|
+
)
|
822
|
+
|
823
|
+
try:
|
824
|
+
# Copy and insert records
|
825
|
+
for record in batch:
|
826
|
+
new_record = copy_instance(record)
|
827
|
+
pg_session.add(new_record)
|
828
|
+
|
829
|
+
# Commit batch
|
830
|
+
pg_session.commit()
|
831
|
+
except Exception as e:
|
832
|
+
pg_session.rollback()
|
833
|
+
typer.echo(f"Error migrating batch in {table_name}: {str(e)}")
|
834
|
+
raise
|
835
|
+
|
836
|
+
# Update progress
|
837
|
+
processed += len(batch)
|
838
|
+
progress.update(len(batch))
|
839
|
+
|
840
|
+
# Update sequence after migrating each table
|
841
|
+
update_sequence(pg_session, model)
|
842
|
+
|
843
|
+
typer.echo(f"Successfully migrated {processed} records from {table_name:<{max_name_length}}")
|
844
|
+
|
845
|
+
typer.echo("Migration completed successfully!")
|
846
|
+
|
847
|
+
finally:
|
848
|
+
# Restore original database path
|
849
|
+
settings.database_path = original_database_path
|
850
|
+
|
851
|
+
except Exception as e:
|
852
|
+
pg_session.rollback()
|
853
|
+
typer.echo(f"Error during migration: {str(e)}", err=True)
|
854
|
+
raise typer.Exit(code=1)
|
855
|
+
|
856
|
+
finally:
|
857
|
+
try:
|
858
|
+
sqlite_session.close()
|
859
|
+
pg_session.close()
|
860
|
+
except:
|
861
|
+
pass
|
862
|
+
|
863
|
+
|
864
|
+
if __name__ == "__main__":
|
865
|
+
app()
|