Skip to content

Commit dd841b5

Browse files
feat: ajout d'une fonctionnalité d'importation de l'historique shell avec prévisualisation et importation des nouvelles commandes
1 parent 116ad90 commit dd841b5

6 files changed

Lines changed: 703 additions & 1 deletion

File tree

backend/api/routers/imports.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import re
4+
35
from fastapi import APIRouter, Depends, HTTPException, status
46
from pydantic import BaseModel, Field
57
from sqlalchemy import select
@@ -39,6 +41,36 @@ class ImportResponse(BaseModel):
3941
commands_created: int
4042

4143

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+
4274
def _normalize_tags(raw: list[str]) -> list[str]:
4375
names: list[str] = []
4476
seen: set[str] = set()
@@ -81,6 +113,148 @@ def _get_or_create_tags(db: Session, raw_names: list[str]) -> list[Tag]:
81113
)
82114

83115

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+
84258
@router.post("", response_model=ImportResponse, status_code=status.HTTP_200_OK)
85259
def import_vault(
86260
payload: ImportRequest, db: Session = Depends(get_db)
@@ -137,3 +311,70 @@ def import_vault(
137311
groups_created=len(payload.groups),
138312
commands_created=len(payload.commands),
139313
)
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+
)

backend/tests/test_commands_groups_search.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,82 @@ def test_top_copied_commands_returns_sorted_by_copy_count(client):
379379
# Ensure sorted by copy_count desc for these three
380380
top_three = [item for item in items if item["id"] in {c1["id"], c2["id"], c3["id"]}]
381381
assert [item["id"] for item in top_three][:3] == [c2["id"], c1["id"], c3["id"]]
382+
383+
384+
def test_import_history_preview_and_import_creates_only_new_commands(client):
385+
login = _login(client, "admin", "admin")
386+
token = login["access_token"]
387+
388+
res = client.post(
389+
"/auth/change-password",
390+
json={"old_password": "admin", "new_password": "admin123"},
391+
headers=_auth_headers(token),
392+
)
393+
assert res.status_code == 200
394+
395+
login2 = _login(client, "admin", "admin123")
396+
token2 = login2["access_token"]
397+
398+
res = client.post(
399+
"/groups", json={"name": "Projet A"}, headers=_auth_headers(token2)
400+
)
401+
assert res.status_code == 201
402+
group = res.json()
403+
404+
# Existing command in DB (should be treated as duplicate)
405+
res = client.post(
406+
"/commands",
407+
json={
408+
"group_id": group["id"],
409+
"title": "Docker list",
410+
"command": "docker ps -a",
411+
"tags": [],
412+
},
413+
headers=_auth_headers(token2),
414+
)
415+
assert res.status_code == 201
416+
417+
history = "\n".join(
418+
[
419+
"cd /tmp",
420+
"ls -la",
421+
": 1712345678:0;docker ps -a",
422+
"docker ps -a",
423+
"docker system prune -af",
424+
"echo hello",
425+
]
426+
)
427+
428+
res = client.post(
429+
"/import/history/preview",
430+
json={"group_id": group["id"], "history": history, "tags": ["devops"]},
431+
headers=_auth_headers(token2),
432+
)
433+
assert res.status_code == 200
434+
body = res.json()
435+
assert body["total_lines"] == 6
436+
assert body["parsed"] == 6
437+
assert body["created_candidates"] == 2
438+
assert body["duplicate_candidates"] == 2
439+
assert body["noise_candidates"] == 2
440+
441+
res = client.post(
442+
"/import/history",
443+
json={"group_id": group["id"], "history": history, "tags": ["devops"]},
444+
headers=_auth_headers(token2),
445+
)
446+
assert res.status_code == 200
447+
body2 = res.json()
448+
assert body2["created"] == 2
449+
assert body2["skipped_duplicates"] == 2
450+
assert body2["skipped_noise"] == 2
451+
452+
res = client.get(
453+
"/commands", params={"group_id": group["id"]}, headers=_auth_headers(token2)
454+
)
455+
assert res.status_code == 200
456+
cmds = res.json()
457+
assert len(cmds) == 3
458+
created_commands = {c["command"] for c in cmds}
459+
assert "docker system prune -af" in created_commands
460+
assert "echo hello" in created_commands

frontend/src/components/AppSidebar.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface SidebarProps {
2121
onNewGroup: () => void;
2222
onExport: () => void;
2323
onImport: (file: File) => void;
24+
onImportHistory: () => void;
2425
onLogout: () => void;
2526
dark: boolean;
2627
onToggleTheme: () => void;
@@ -32,7 +33,7 @@ export default function AppSidebar({
3233
groups, stats, activeView, onViewChange, onNewGroup,
3334
loading = false,
3435
importing = false,
35-
onExport, onImport, onLogout, dark, onToggleTheme, onEditGroup, onDeleteGroup,
36+
onExport, onImport, onImportHistory, onLogout, dark, onToggleTheme, onEditGroup, onDeleteGroup,
3637
}: SidebarProps) {
3738
const [mobileOpen, setMobileOpen] = useState(false);
3839
const fileRef = useRef<HTMLInputElement>(null);
@@ -162,6 +163,14 @@ export default function AppSidebar({
162163
<Upload className="w-4 h-4" />
163164
Importer
164165
</DropdownMenuItem>
166+
<DropdownMenuItem
167+
onSelect={onImportHistory}
168+
disabled={loading || importing}
169+
className="gap-2"
170+
>
171+
<Upload className="w-4 h-4" />
172+
Importer historique
173+
</DropdownMenuItem>
165174
<DropdownMenuItem onSelect={onExport} className="gap-2">
166175
<Download className="w-4 h-4" />
167176
Exporter

frontend/src/components/HelpModal.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ export default function HelpModal({ open, onClose }: Props) {
121121
Export (JSON/CSV/PDF) et import JSON sont accessibles depuis la sidebar.
122122
Tu peux aussi exporter une sélection via la barre d’actions.
123123
</p>
124+
<p>
125+
Tu peux aussi importer ton <strong className="text-foreground">historique shell</strong> (bash/zsh) via “Importer historique”.
126+
</p>
124127
</div>
125128
</section>
126129

0 commit comments

Comments
 (0)