feat(v2): Import archive .msgs files + DB-based message history

Migration now imports all archive files (oldest first) in addition to the
live .msgs file, with deduplication. Archives endpoint and message history
now query SQLite by date instead of reading .msgs files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-01 13:05:33 +01:00
parent 97a2014af2
commit 752c60f02d
3 changed files with 190 additions and 36 deletions
+124 -23
View File
@@ -5,22 +5,22 @@ Runs automatically on startup if .msgs file exists and database is empty.
Can also be run manually: python -m app.migrate_v1
Migrates:
- Live .msgs file (today's messages)
- Archive .msgs files (historical messages)
- Channel messages (CHAN, SENT_CHAN)
- Direct messages (PRIV, SENT_MSG)
"""
import json
import logging
import time
from pathlib import Path
from datetime import datetime
from typing import Optional
from typing import Optional, List
logger = logging.getLogger(__name__)
def _find_msgs_file(data_dir: Path, device_name: str) -> Optional[Path]:
"""Find the .msgs file for the given device name."""
"""Find the live .msgs file for the given device name."""
msgs_file = data_dir / f"{device_name}.msgs"
if msgs_file.exists():
return msgs_file
@@ -35,9 +35,41 @@ def _find_msgs_file(data_dir: Path, device_name: str) -> Optional[Path]:
return None
def _find_archive_files(data_dir: Path, device_name: str) -> List[Path]:
"""Find all archive .msgs files, sorted oldest first."""
archive_files = []
# Check common archive locations
archive_dirs = [
data_dir / 'archive', # /data/archive/
data_dir.parent / 'archive', # sibling archive dir
]
for archive_dir in archive_dirs:
if archive_dir.exists():
# Pattern: DeviceName.YYYY-MM-DD.msgs
for f in archive_dir.glob(f"{device_name}.*.msgs"):
# Validate it's an archive file (has date in name)
parts = f.stem.split('.')
if len(parts) >= 2:
archive_files.append(f)
# Also check data_dir itself for archives
for f in data_dir.glob(f"{device_name}.*.msgs"):
parts = f.stem.split('.')
if len(parts) >= 2 and f not in archive_files:
archive_files.append(f)
# Sort by filename (which sorts by date since format is Name.YYYY-MM-DD)
archive_files.sort(key=lambda f: f.name)
return archive_files
def migrate_v1_data(db, data_dir: Path, device_name: str) -> dict:
"""
Import v1 .msgs data into v2 SQLite database.
Imports both live .msgs file and all archive files.
Args:
db: Database instance
@@ -47,26 +79,69 @@ def migrate_v1_data(db, data_dir: Path, device_name: str) -> dict:
Returns:
dict with migration stats
"""
msgs_file = _find_msgs_file(data_dir, device_name)
if not msgs_file:
logger.info("No .msgs file found, skipping v1 migration")
return {'status': 'skipped', 'reason': 'no_msgs_file'}
stats = {
'channel_messages': 0,
'direct_messages': 0,
'skipped': 0,
'errors': 0,
'file': str(msgs_file),
'files_processed': 0,
}
logger.info(f"Starting v1 data migration from {msgs_file}")
# Collect all files to import: archives first (oldest), then live
files_to_import = []
archive_files = _find_archive_files(data_dir, device_name)
if archive_files:
files_to_import.extend(archive_files)
logger.info(f"Found {len(archive_files)} archive files to migrate")
live_file = _find_msgs_file(data_dir, device_name)
if live_file:
files_to_import.append(live_file)
if not files_to_import:
logger.info("No .msgs files found, skipping v1 migration")
return {'status': 'skipped', 'reason': 'no_msgs_files'}
logger.info(f"Starting v1 data migration: {len(files_to_import)} files to process")
# Track seen timestamps+text to avoid duplicates across archive and live file
seen_channel = set()
seen_dm = set()
for msgs_file in files_to_import:
file_stats = _import_msgs_file(
db, msgs_file, device_name, seen_channel, seen_dm
)
stats['channel_messages'] += file_stats['channel_messages']
stats['direct_messages'] += file_stats['direct_messages']
stats['skipped'] += file_stats['skipped']
stats['errors'] += file_stats['errors']
stats['files_processed'] += 1
stats['status'] = 'completed'
logger.info(
f"v1 migration complete: {stats['files_processed']} files, "
f"{stats['channel_messages']} channel msgs, "
f"{stats['direct_messages']} DMs, {stats['skipped']} skipped, "
f"{stats['errors']} errors"
)
return stats
def _import_msgs_file(db, msgs_file: Path, device_name: str,
seen_channel: set, seen_dm: set) -> dict:
"""Import a single .msgs file. Returns per-file stats."""
stats = {'channel_messages': 0, 'direct_messages': 0, 'skipped': 0, 'errors': 0}
logger.info(f"Importing {msgs_file.name}...")
try:
lines = msgs_file.read_text(encoding='utf-8', errors='replace').splitlines()
except Exception as e:
logger.error(f"Failed to read .msgs file: {e}")
return {'status': 'error', 'reason': str(e)}
logger.error(f"Failed to read {msgs_file}: {e}")
stats['errors'] += 1
return stats
for line_num, raw_line in enumerate(lines, 1):
raw_line = raw_line.strip()
@@ -83,13 +158,38 @@ def migrate_v1_data(db, data_dir: Path, device_name: str) -> dict:
try:
if msg_type in ('CHAN', 'SENT_CHAN'):
# Dedup key: timestamp + first 50 chars of text
ts = entry.get('timestamp', 0)
text = entry.get('text', '')[:50]
dedup = (ts, text)
if dedup in seen_channel:
stats['skipped'] += 1
continue
seen_channel.add(dedup)
_migrate_channel_msg(db, entry, device_name)
stats['channel_messages'] += 1
elif msg_type == 'PRIV':
ts = entry.get('timestamp', 0)
text = entry.get('text', '')[:50]
dedup = (ts, text)
if dedup in seen_dm:
stats['skipped'] += 1
continue
seen_dm.add(dedup)
_migrate_dm_incoming(db, entry)
stats['direct_messages'] += 1
elif msg_type == 'SENT_MSG':
if entry.get('txt_type', 0) == 0: # Only private messages
ts = entry.get('timestamp', 0)
text = entry.get('text', '')[:50]
dedup = (ts, text)
if dedup in seen_dm:
stats['skipped'] += 1
continue
seen_dm.add(dedup)
_migrate_dm_outgoing(db, entry, device_name)
stats['direct_messages'] += 1
else:
@@ -99,13 +199,12 @@ def migrate_v1_data(db, data_dir: Path, device_name: str) -> dict:
except Exception as e:
stats['errors'] += 1
if stats['errors'] <= 5:
logger.warning(f"Migration error on line {line_num}: {e}")
logger.warning(f"Migration error in {msgs_file.name} line {line_num}: {e}")
stats['status'] = 'completed'
logger.info(
f"v1 migration complete: {stats['channel_messages']} channel msgs, "
f"{stats['direct_messages']} DMs, {stats['skipped']} skipped, "
f"{stats['errors']} errors"
f" {msgs_file.name}: {stats['channel_messages']} chan, "
f"{stats['direct_messages']} DMs, {stats['skipped']} skip, "
f"{stats['errors']} err"
)
return stats
@@ -182,8 +281,6 @@ def _migrate_dm_outgoing(db, entry: dict, device_name: str):
return
# For outgoing DMs, we don't have recipient pubkey in v1 data.
# Use empty string — the messages will appear once a contact with
# matching name is found.
# In v1, conversation_id was "name_{recipient}" — we store the name
# in raw_json for reference.
recipient = entry.get('recipient', entry.get('name', ''))
@@ -235,9 +332,13 @@ def _lookup_pubkey_by_name(db, name: str) -> Optional[str]:
def should_migrate(db, data_dir: Path, device_name: str) -> bool:
"""Check if migration is needed: .msgs exists and DB has no messages."""
msgs_file = _find_msgs_file(data_dir, device_name)
if not msgs_file:
"""Check if migration is needed: .msgs files exist and DB has no messages."""
# Check for live file
has_live = _find_msgs_file(data_dir, device_name) is not None
# Check for archive files
has_archives = len(_find_archive_files(data_dir, device_name)) > 0
if not has_live and not has_archives:
return False
# Only migrate if DB is empty (no channel messages and no DMs)