|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import re |
| 4 | + |
3 | 5 | from fastapi import APIRouter, Depends, HTTPException, status |
4 | 6 | from pydantic import BaseModel, Field |
5 | 7 | from sqlalchemy import select |
@@ -39,6 +41,36 @@ class ImportResponse(BaseModel): |
39 | 41 | commands_created: int |
40 | 42 |
|
41 | 43 |
|
| 44 | +class HistoryImportRequest(BaseModel): |
| 45 | + group_id: int |
| 46 | + history: str = Field(min_length=1) |
| 47 | + tags: list[str] = Field(default_factory=list) |
| 48 | + |
| 49 | + |
| 50 | +class HistoryImportPreviewItem(BaseModel): |
| 51 | + command: str |
| 52 | + status: str # new | duplicate | noise |
| 53 | + reason: str | None = None |
| 54 | + |
| 55 | + |
| 56 | +class HistoryImportPreviewResponse(BaseModel): |
| 57 | + total_lines: int |
| 58 | + parsed: int |
| 59 | + created_candidates: int |
| 60 | + duplicate_candidates: int |
| 61 | + noise_candidates: int |
| 62 | + truncated: bool |
| 63 | + items: list[HistoryImportPreviewItem] |
| 64 | + |
| 65 | + |
| 66 | +class HistoryImportResponse(BaseModel): |
| 67 | + created: int |
| 68 | + skipped_duplicates: int |
| 69 | + skipped_noise: int |
| 70 | + total_lines: int |
| 71 | + parsed: int |
| 72 | + |
| 73 | + |
42 | 74 | def _normalize_tags(raw: list[str]) -> list[str]: |
43 | 75 | names: list[str] = [] |
44 | 76 | seen: set[str] = set() |
@@ -81,6 +113,148 @@ def _get_or_create_tags(db: Session, raw_names: list[str]) -> list[Tag]: |
81 | 113 | ) |
82 | 114 |
|
83 | 115 |
|
| 116 | +_WS_RE = re.compile(r"\s+") |
| 117 | + |
| 118 | + |
| 119 | +def _normalize_history_line(raw: str) -> str: |
| 120 | + line = (raw or "").strip() |
| 121 | + if not line: |
| 122 | + return "" |
| 123 | + |
| 124 | + # zsh format: ": 1712345678:0;the command" |
| 125 | + if line.startswith(":") and ";" in line: |
| 126 | + line = line.split(";", 1)[1].strip() |
| 127 | + |
| 128 | + return line |
| 129 | + |
| 130 | + |
| 131 | +def _normalize_command_text(cmd: str) -> str: |
| 132 | + cmd = (cmd or "").strip() |
| 133 | + cmd = _WS_RE.sub(" ", cmd) |
| 134 | + return cmd |
| 135 | + |
| 136 | + |
| 137 | +def _is_noise_command(cmd: str) -> tuple[bool, str | None]: |
| 138 | + if not cmd: |
| 139 | + return True, "empty" |
| 140 | + |
| 141 | + if cmd.startswith("#"): |
| 142 | + return True, "comment" |
| 143 | + |
| 144 | + if len(cmd) < 3: |
| 145 | + return True, "too short" |
| 146 | + |
| 147 | + first = cmd.split(" ", 1)[0].lower() |
| 148 | + |
| 149 | + # common noise commands in history |
| 150 | + if first in {"cd", "ls", "pwd", "clear", "exit", "history", "alias", "unalias"}: |
| 151 | + return True, f"noise: {first}" |
| 152 | + |
| 153 | + return False, None |
| 154 | + |
| 155 | + |
| 156 | +def _make_title_from_command(cmd: str) -> str: |
| 157 | + tokens = cmd.split(" ") |
| 158 | + if not tokens: |
| 159 | + return "Imported command" |
| 160 | + |
| 161 | + if len(tokens) == 1: |
| 162 | + base = tokens[0] |
| 163 | + else: |
| 164 | + base = f"{tokens[0]} {tokens[1]}".strip() |
| 165 | + |
| 166 | + if len(base) < 3: |
| 167 | + base = tokens[0] |
| 168 | + |
| 169 | + # Keep within schema max_length=160 |
| 170 | + return base[:160] |
| 171 | + |
| 172 | + |
| 173 | +def _preview_history_import( |
| 174 | + db: Session, |
| 175 | + history: str, |
| 176 | + max_items: int = 300, |
| 177 | +) -> tuple[list[HistoryImportPreviewItem], int, int, int, int, int, bool]: |
| 178 | + lines = history.splitlines() |
| 179 | + total_lines = len(lines) |
| 180 | + |
| 181 | + existing = { |
| 182 | + _normalize_command_text(cmd) |
| 183 | + for cmd in db.scalars(select(Command.command)).all() |
| 184 | + if _normalize_command_text(cmd) |
| 185 | + } |
| 186 | + |
| 187 | + seen_in_payload: set[str] = set() |
| 188 | + items: list[HistoryImportPreviewItem] = [] |
| 189 | + |
| 190 | + parsed = 0 |
| 191 | + created_candidates = 0 |
| 192 | + duplicate_candidates = 0 |
| 193 | + noise_candidates = 0 |
| 194 | + truncated = False |
| 195 | + |
| 196 | + for raw in lines: |
| 197 | + normalized_line = _normalize_history_line(raw) |
| 198 | + normalized_cmd = _normalize_command_text(normalized_line) |
| 199 | + if not normalized_cmd: |
| 200 | + continue |
| 201 | + |
| 202 | + parsed += 1 |
| 203 | + |
| 204 | + is_noise, reason = _is_noise_command(normalized_cmd) |
| 205 | + if is_noise: |
| 206 | + noise_candidates += 1 |
| 207 | + if len(items) < max_items: |
| 208 | + items.append( |
| 209 | + HistoryImportPreviewItem( |
| 210 | + command=normalized_cmd, |
| 211 | + status="noise", |
| 212 | + reason=reason, |
| 213 | + ) |
| 214 | + ) |
| 215 | + else: |
| 216 | + truncated = True |
| 217 | + continue |
| 218 | + |
| 219 | + if normalized_cmd in seen_in_payload: |
| 220 | + duplicate_candidates += 1 |
| 221 | + if len(items) < max_items: |
| 222 | + items.append( |
| 223 | + HistoryImportPreviewItem( |
| 224 | + command=normalized_cmd, |
| 225 | + status="duplicate", |
| 226 | + reason="duplicate in history", |
| 227 | + ) |
| 228 | + ) |
| 229 | + else: |
| 230 | + truncated = True |
| 231 | + continue |
| 232 | + |
| 233 | + seen_in_payload.add(normalized_cmd) |
| 234 | + |
| 235 | + if normalized_cmd in existing: |
| 236 | + duplicate_candidates += 1 |
| 237 | + if len(items) < max_items: |
| 238 | + items.append( |
| 239 | + HistoryImportPreviewItem( |
| 240 | + command=normalized_cmd, |
| 241 | + status="duplicate", |
| 242 | + reason="already exists", |
| 243 | + ) |
| 244 | + ) |
| 245 | + else: |
| 246 | + truncated = True |
| 247 | + continue |
| 248 | + |
| 249 | + created_candidates += 1 |
| 250 | + if len(items) < max_items: |
| 251 | + items.append(HistoryImportPreviewItem(command=normalized_cmd, status="new")) |
| 252 | + else: |
| 253 | + truncated = True |
| 254 | + |
| 255 | + return items, total_lines, parsed, created_candidates, duplicate_candidates, noise_candidates, truncated |
| 256 | + |
| 257 | + |
84 | 258 | @router.post("", response_model=ImportResponse, status_code=status.HTTP_200_OK) |
85 | 259 | def import_vault( |
86 | 260 | payload: ImportRequest, db: Session = Depends(get_db) |
@@ -137,3 +311,70 @@ def import_vault( |
137 | 311 | groups_created=len(payload.groups), |
138 | 312 | commands_created=len(payload.commands), |
139 | 313 | ) |
| 314 | + |
| 315 | + |
| 316 | +@router.post("/history/preview", response_model=HistoryImportPreviewResponse, status_code=status.HTTP_200_OK) |
| 317 | +def preview_history_import(payload: HistoryImportRequest, db: Session = Depends(get_db)) -> HistoryImportPreviewResponse: |
| 318 | + group = db.get(Group, payload.group_id) |
| 319 | + if group is None: |
| 320 | + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Group does not exist") |
| 321 | + |
| 322 | + items, total_lines, parsed, created, dupes, noise, truncated = _preview_history_import( |
| 323 | + db=db, |
| 324 | + history=payload.history, |
| 325 | + ) |
| 326 | + |
| 327 | + return HistoryImportPreviewResponse( |
| 328 | + total_lines=total_lines, |
| 329 | + parsed=parsed, |
| 330 | + created_candidates=created, |
| 331 | + duplicate_candidates=dupes, |
| 332 | + noise_candidates=noise, |
| 333 | + truncated=truncated, |
| 334 | + items=items, |
| 335 | + ) |
| 336 | + |
| 337 | + |
| 338 | +@router.post("/history", response_model=HistoryImportResponse, status_code=status.HTTP_200_OK) |
| 339 | +def import_history(payload: HistoryImportRequest, db: Session = Depends(get_db)) -> HistoryImportResponse: |
| 340 | + group = db.get(Group, payload.group_id) |
| 341 | + if group is None: |
| 342 | + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Group does not exist") |
| 343 | + |
| 344 | + items, total_lines, parsed, created, dupes, noise, _truncated = _preview_history_import( |
| 345 | + db=db, |
| 346 | + history=payload.history, |
| 347 | + max_items=10_000_000, # no truncation for actual import |
| 348 | + ) |
| 349 | + |
| 350 | + tags = _normalize_tags(payload.tags) |
| 351 | + |
| 352 | + try: |
| 353 | + for item in items: |
| 354 | + if item.status != "new": |
| 355 | + continue |
| 356 | + |
| 357 | + cmd = Command( |
| 358 | + group_id=payload.group_id, |
| 359 | + title=_make_title_from_command(item.command), |
| 360 | + command=item.command, |
| 361 | + description=None, |
| 362 | + default_variables={}, |
| 363 | + is_favorite=False, |
| 364 | + copy_count=0, |
| 365 | + ) |
| 366 | + cmd.tag_entities = _get_or_create_tags(db, tags) |
| 367 | + db.add(cmd) |
| 368 | + |
| 369 | + db.commit() |
| 370 | + except Exception: |
| 371 | + db.rollback() |
| 372 | + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Import failed") |
| 373 | + |
| 374 | + return HistoryImportResponse( |
| 375 | + created=created, |
| 376 | + skipped_duplicates=dupes, |
| 377 | + skipped_noise=noise, |
| 378 | + total_lines=total_lines, |
| 379 | + parsed=parsed, |
| 380 | + ) |
0 commit comments