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.
Files changed (111) hide show
  1. memos/__init__.py +6 -0
  2. memos/cmds/__init__.py +0 -0
  3. memos/cmds/library.py +1289 -0
  4. memos/cmds/plugin.py +96 -0
  5. memos/commands.py +865 -0
  6. memos/config.py +225 -0
  7. memos/crud.py +605 -0
  8. memos/databases/__init__.py +0 -0
  9. memos/databases/initializers.py +481 -0
  10. memos/dataset_extractor_for_florence.py +165 -0
  11. memos/dataset_extractor_for_internvl2.py +192 -0
  12. memos/default_config.yaml +88 -0
  13. memos/embedding.py +129 -0
  14. memos/frame_extractor.py +53 -0
  15. memos/logging_config.py +35 -0
  16. memos/main.py +104 -0
  17. memos/migrations/alembic/README +1 -0
  18. memos/migrations/alembic/__pycache__/env.cpython-310.pyc +0 -0
  19. memos/migrations/alembic/env.py +108 -0
  20. memos/migrations/alembic/script.py.mako +30 -0
  21. memos/migrations/alembic/versions/00904ac8c6fc_add_indexes_to_entitymodel.py +63 -0
  22. memos/migrations/alembic/versions/04acdaf75664_add_indices_to_entitytags_and_metadata.py +86 -0
  23. memos/migrations/alembic/versions/12504c5b1d3c_add_extra_columns_for_embedding.py +67 -0
  24. memos/migrations/alembic/versions/31a1ad0e10b3_add_entity_plugin_status.py +71 -0
  25. memos/migrations/alembic/versions/__pycache__/00904ac8c6fc_add_indexes_to_entitymodel.cpython-310.pyc +0 -0
  26. memos/migrations/alembic/versions/__pycache__/04acdaf75664_add_indices_to_entitytags_and_metadata.cpython-310.pyc +0 -0
  27. memos/migrations/alembic/versions/__pycache__/12504c5b1d3c_add_extra_columns_for_embedding.cpython-310.pyc +0 -0
  28. memos/migrations/alembic/versions/__pycache__/20f5ecab014d_add_entity_plugin_status.cpython-310.pyc +0 -0
  29. memos/migrations/alembic/versions/__pycache__/31a1ad0e10b3_add_entity_plugin_status.cpython-310.pyc +0 -0
  30. memos/migrations/alembic/versions/__pycache__/4fcb062c5128_add_extra_columns_for_embedding.cpython-310.pyc +0 -0
  31. memos/migrations/alembic/versions/__pycache__/d10c55fbb7d2_add_index_for_entity_file_type_group_.cpython-310.pyc +0 -0
  32. memos/migrations/alembic/versions/__pycache__/f8f158182416_add_active_app_index.cpython-310.pyc +0 -0
  33. memos/migrations/alembic/versions/d10c55fbb7d2_add_index_for_entity_file_type_group_.py +44 -0
  34. memos/migrations/alembic/versions/f8f158182416_add_active_app_index.py +75 -0
  35. memos/migrations/alembic.ini +116 -0
  36. memos/migrations.py +19 -0
  37. memos/models.py +199 -0
  38. memos/plugins/__init__.py +0 -0
  39. memos/plugins/ocr/__init__.py +0 -0
  40. memos/plugins/ocr/main.py +251 -0
  41. memos/plugins/ocr/models/ch_PP-OCRv4_det_infer.onnx +0 -0
  42. memos/plugins/ocr/models/ch_PP-OCRv4_rec_infer.onnx +0 -0
  43. memos/plugins/ocr/models/ch_ppocr_mobile_v2.0_cls_train.onnx +0 -0
  44. memos/plugins/ocr/ppocr-gpu.yaml +43 -0
  45. memos/plugins/ocr/ppocr.yaml +44 -0
  46. memos/plugins/ocr/server.py +227 -0
  47. memos/plugins/ocr/temp_ppocr.yaml +42 -0
  48. memos/plugins/vlm/__init__.py +0 -0
  49. memos/plugins/vlm/main.py +251 -0
  50. memos/prepare_dataset.py +107 -0
  51. memos/process_webp.py +55 -0
  52. memos/read_metadata.py +32 -0
  53. memos/record.py +358 -0
  54. memos/schemas.py +289 -0
  55. memos/search.py +1198 -0
  56. memos/server.py +883 -0
  57. memos/shotsum.py +105 -0
  58. memos/shotsum_with_ocr.py +145 -0
  59. memos/simple_tokenizer/dict/README.md +31 -0
  60. memos/simple_tokenizer/dict/hmm_model.utf8 +34 -0
  61. memos/simple_tokenizer/dict/idf.utf8 +258826 -0
  62. memos/simple_tokenizer/dict/jieba.dict.utf8 +348982 -0
  63. memos/simple_tokenizer/dict/pos_dict/char_state_tab.utf8 +6653 -0
  64. memos/simple_tokenizer/dict/pos_dict/prob_emit.utf8 +166 -0
  65. memos/simple_tokenizer/dict/pos_dict/prob_start.utf8 +259 -0
  66. memos/simple_tokenizer/dict/pos_dict/prob_trans.utf8 +5222 -0
  67. memos/simple_tokenizer/dict/stop_words.utf8 +1534 -0
  68. memos/simple_tokenizer/dict/user.dict.utf8 +4 -0
  69. memos/simple_tokenizer/linux/libsimple.so +0 -0
  70. memos/simple_tokenizer/macos/libsimple.dylib +0 -0
  71. memos/simple_tokenizer/windows/simple.dll +0 -0
  72. memos/static/_app/immutable/assets/0.e250c031.css +1 -0
  73. memos/static/_app/immutable/assets/_layout.e7937cfe.css +1 -0
  74. memos/static/_app/immutable/chunks/index.5c08976b.js +1 -0
  75. memos/static/_app/immutable/chunks/index.60ee613b.js +4 -0
  76. memos/static/_app/immutable/chunks/runtime.a7926cf6.js +5 -0
  77. memos/static/_app/immutable/chunks/scheduler.5c1cff6e.js +1 -0
  78. memos/static/_app/immutable/chunks/singletons.583bdf4e.js +1 -0
  79. memos/static/_app/immutable/entry/app.666c1643.js +1 -0
  80. memos/static/_app/immutable/entry/start.aed5c701.js +3 -0
  81. memos/static/_app/immutable/nodes/0.5862ea38.js +7 -0
  82. memos/static/_app/immutable/nodes/1.35378a5e.js +1 -0
  83. memos/static/_app/immutable/nodes/2.1ccf9ea5.js +81 -0
  84. memos/static/_app/version.json +1 -0
  85. memos/static/app.html +36 -0
  86. memos/static/favicon.png +0 -0
  87. memos/static/logos/memos_logo_1024.png +0 -0
  88. memos/static/logos/memos_logo_1024@2x.png +0 -0
  89. memos/static/logos/memos_logo_128.png +0 -0
  90. memos/static/logos/memos_logo_128@2x.png +0 -0
  91. memos/static/logos/memos_logo_16.png +0 -0
  92. memos/static/logos/memos_logo_16@2x.png +0 -0
  93. memos/static/logos/memos_logo_256.png +0 -0
  94. memos/static/logos/memos_logo_256@2x.png +0 -0
  95. memos/static/logos/memos_logo_32.png +0 -0
  96. memos/static/logos/memos_logo_32@2x.png +0 -0
  97. memos/static/logos/memos_logo_512.png +0 -0
  98. memos/static/logos/memos_logo_512@2x.png +0 -0
  99. memos/static/logos/memos_logo_64.png +0 -0
  100. memos/static/logos/memos_logo_64@2x.png +0 -0
  101. memos/test_server.py +802 -0
  102. memos/utils.py +49 -0
  103. memos_ml_backends/florence2_server.py +176 -0
  104. memos_ml_backends/qwen2vl_server.py +182 -0
  105. memos_ml_backends/schemas.py +48 -0
  106. pensiev-0.25.5.dist-info/LICENSE +201 -0
  107. pensiev-0.25.5.dist-info/METADATA +541 -0
  108. pensiev-0.25.5.dist-info/RECORD +111 -0
  109. pensiev-0.25.5.dist-info/WHEEL +5 -0
  110. pensiev-0.25.5.dist-info/entry_points.txt +2 -0
  111. 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()