Compare commits

...
Sign in to create a new pull request.

6 commits
master ... fe

Author SHA1 Message Date
5f155f6cb2
directory for external files 2025-10-17 21:35:13 +02:00
0c9a3a8527
formatting 2025-10-17 21:12:49 +02:00
e3be433e37
fix: FastAPI Jinja wrapper 2025-10-17 21:12:38 +02:00
7c0ef15567
fix: front-end autowire 2025-10-17 20:41:52 +02:00
5224fe78b6
fix: Group tree path 2025-10-16 23:10:04 +02:00
e310930d9e
Frontend basics 2025-10-16 23:07:06 +02:00
12 changed files with 613 additions and 67 deletions

2
.gitignore vendored
View file

@ -6,3 +6,5 @@ uv.lock
__pycache__/ __pycache__/
**/dist/ **/dist/
dump/

View file

@ -1,5 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List from enum import Enum
from typing import List, Optional
@dataclass @dataclass
@ -10,6 +11,49 @@ class Request:
commit_url: str commit_url: str
changelist: List[str] changelist: List[str]
@dataclass @dataclass
class Response: class Response:
status: int status: int
class EntryKind(str, Enum):
simple = "simple"
complex = "complex"
@dataclass
class EntrySimpleDTO:
key: str
value: str # mapowane na title/password
@dataclass
class EntryComplexDTO:
title: str
username: str
password: str
url: Optional[str] = None
notes: Optional[str] = None
@dataclass
class GroupDTO:
name: str
path: str
@dataclass
class EntryNodeDTO:
kind: EntryKind
title: str
path: str
@dataclass
class NodeDTO:
# Drzewo: grupa lub wpisy w grupach
name: str
path: str
groups: List["NodeDTO"]
entries: List[EntryNodeDTO]

View file

@ -1,6 +1,10 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends, Body, Query
from fastapi.responses import JSONResponse
from fastapi_utils.cbv import cbv
from injectable import inject
from app.api.models import Request, Response from app.api.models import *
from app.web import PasswordsController
router = APIRouter() router = APIRouter()
@ -14,6 +18,92 @@ async def root():
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}
@router.post("/ci", summary="CI Webhook") @router.post("/ci", summary="CI Webhook")
async def ci(request: Request): async def ci(request: Request):
return Response(200) return Response(200)
class AutowireSupport:
@staticmethod
def password_controller():
return inject(PasswordsController)
@cbv(router)
class TreeController:
svc: PasswordsController = Depends(AutowireSupport.password_controller)
@router.get("/tree", response_model=NodeDTO)
def get_tree(self) -> NodeDTO:
return self.svc.get_tree()
@router.post("/group", response_model=GroupDTO)
def create_group(
self,
name: str = Body(..., embed=True),
parent_path: str | None = Body(None, embed=True),
) -> GroupDTO:
return self.svc.create_group(name=name, parent_path=parent_path)
@router.patch("/group/rename", response_model=GroupDTO)
def rename_group(
self,
path: str = Body(..., embed=True),
new_name: str = Body(..., embed=True),
) -> GroupDTO:
return self.svc.rename_group(path, new_name)
@router.delete("/group")
def delete_group(
self,
path: str = Query(...),
):
self.svc.delete_group(path)
return JSONResponse({"ok": True})
@router.post("/entry", response_model=NodeDTO)
def create_entry(
self,
kind: EntryKind = Body(..., embed=True),
parent_path: str = Body(..., embed=True),
data: dict = Body(..., embed=True),
) -> NodeDTO:
if kind == EntryKind.simple:
dto = EntrySimpleDTO(**data)
return self.svc.create_entry_simple(parent_path, dto)
dto = EntryComplexDTO(**data)
return self.svc.create_entry_complex(parent_path, dto)
@router.patch("/entry", response_model=NodeDTO)
def update_entry(
self,
path: str = Body(..., embed=True),
kind: EntryKind = Body(..., embed=True),
data: dict = Body(..., embed=True),
) -> NodeDTO:
if kind == EntryKind.simple:
dto = EntrySimpleDTO(**data)
return self.svc.update_entry_simple(path, dto)
dto = EntryComplexDTO(**data)
return self.svc.update_entry_complex(path, dto)
@router.patch("/entry/move", response_model=NodeDTO)
def move_entry(
self,
path: str = Body(..., embed=True),
target_group_path: str = Body(..., embed=True),
) -> NodeDTO:
return self.svc.move_entry(path, target_group_path)
@router.delete("/entry")
def delete_entry(
self,
path: str = Query(...),
):
self.svc.delete_entry(path)
return JSONResponse({"ok": True})
@router.post("/save")
def save_changes(self):
self.svc.save()
return JSONResponse({"ok": True})

View file

@ -2,19 +2,17 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from fastapi.templating import Jinja2Templates
router = APIRouter() router = APIRouter()
# Inicjalizacja Jinja2 # Inicjalizacja Jinja2
templates_env = Environment( templates = Jinja2Templates(directory="app/templates")
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html", "xml"]),
)
# Przykładowy endpoint HTML # Przykładowy endpoint HTML
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse: async def index(request: Request) -> HTMLResponse:
template = templates_env.get_template("index.html") return templates.TemplateResponse(name="index.html",
html = template.render(title="Strona główna", request=request) request=request,
return HTMLResponse(content=html) context={"title": "Strona Główna"})

View file

@ -2,6 +2,7 @@ import logging
from fastapi import FastAPI from fastapi import FastAPI
from injectable import load_injection_container from injectable import load_injection_container
from fastapi.staticfiles import StaticFiles
from app.config import get_settings from app.config import get_settings
from app.core.core import WebhookProcessor from app.core.core import WebhookProcessor
@ -12,15 +13,16 @@ class KarlApplication:
from starlette.types import Receive, Scope, Send from starlette.types import Receive, Scope, Send
def __init__(self) -> None: def __init__(self) -> None:
self._set_logging() self._set_logging()
_app = FastAPI(title="Karl", version="0.1.0") load_injection_container()
self._set_routes(_app) _instance = FastAPI(title="Karl", version="0.1.0")
self._set_events(_app) self._set_routes(_instance)
self._set_events(_instance)
self._init_services() self._init_services()
self._app = _app self._instance = _instance
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._app.__call__(scope, receive, send) await self._instance.__call__(scope, receive, send)
def _set_logging(self): def _set_logging(self):
logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()]) logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()])
@ -39,19 +41,18 @@ class KarlApplication:
logging_logger.handlers = [external_handler] logging_logger.handlers = [external_handler]
logging_logger.propagate = False logging_logger.propagate = False
def _set_routes(self, app: FastAPI): def _set_routes(self, instance: FastAPI):
from app.core.router import router as core_router from app.core.router import router as core_router
app.include_router(core_router) instance.include_router(core_router)
from app.api.v1 import router as api_v1_router from app.api.v1 import router as api_v1_router
app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"]) instance.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
pass instance.mount("/static", StaticFiles(directory="app/static"), name="static")
def _set_events(self, app: FastAPI): def _set_events(self, instance: FastAPI):
pass pass
def _init_services(self): def _init_services(self):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
load_injection_container()
webhook_service = WebhookProcessor() webhook_service = WebhookProcessor()
logger.info(webhook_service.health) logger.info(webhook_service.health)

View file

@ -2,6 +2,7 @@ import os.path
from injectable import injectable from injectable import injectable
from pykeepass import PyKeePass, create_database, Group from pykeepass import PyKeePass, create_database, Group
import shutil
@injectable(singleton=True) @injectable(singleton=True)
@ -11,30 +12,28 @@ class Passwords:
settings = get_settings() settings = get_settings()
with open(settings.kp.secret, "r") as fh: with open(settings.kp.secret, "r") as fh:
secret = fh.read() secret = fh.read().splitlines()[0]
self._path = settings.kp.file
self._kp_org = self.__get_or_create_store(settings.kp.file, secret) self._kp_org = self._open_or_create(self._path, secret)
self._kp = self.__get_lock(settings.kp.file, secret) self._kp = self._open_lock(self._path, secret)
@staticmethod @staticmethod
def __get_or_create_store(path, passwd) -> PyKeePass: def _open_or_create(path, password) -> PyKeePass:
if os.path.exists(path): if os.path.exists(path):
return PyKeePass( return PyKeePass(path, password=password)
path, return create_database(path, password)
password=passwd,
)
return create_database(path, passwd)
@staticmethod @staticmethod
def __get_lock(path, passwd) -> PyKeePass: def _open_lock(path, password) -> PyKeePass:
lock_path = path + ".lock" lock_path = path + ".lock"
import shutil
shutil.copyfile(path, lock_path) shutil.copyfile(path, lock_path)
return Passwords.__get_or_create_store(lock_path, passwd) return Passwords._open_or_create(lock_path, password)
@property @property
def store(self): def kp(self) -> PyKeePass:
return self._kp.root_group return self._kp
def save(self, group: Group): def save(self):
pass # nadpisz plik źródłowy zmianami z lock
self._kp.save()
shutil.copyfile(self._path + ".lock", self._path)

66
app/static/css/style.css Normal file
View file

@ -0,0 +1,66 @@
html, body {
height: 100%;
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial;
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
height: 100vh;
}
.left {
border-right: 1px solid #ddd;
padding: 12px;
overflow: auto;
}
.right {
padding: 12px;
overflow: auto;
}
ul {
list-style: none;
padding-left: 16px;
}
.node {
cursor: pointer;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.row {
margin-bottom: 8px;
display: flex;
gap: 8px;
align-items: center;
}
input[type="text"], input[type="url"], input[type="password"], textarea, select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 6px;
}
.section {
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.danger {
color: #b10000
}

187
app/static/js/app.js Normal file
View file

@ -0,0 +1,187 @@
let selectedNode = { type: "group", path: "/" }; // type: "group" | "entry"
let currentKind = "simple";
const treeDiv = document.getElementById("tree");
const currentPathEl = document.getElementById("current-path");
const editorSimple = document.getElementById("editor-simple");
const editorComplex = document.getElementById("editor-complex");
const kindSelect = document.getElementById("entry-kind");
function showEditor(kind) {
editorSimple.style.display = (kind === "simple") ? "block" : "none";
editorComplex.style.display = (kind === "complex") ? "block" : "none";
}
kindSelect.addEventListener("change", () => {
currentKind = kindSelect.value;
showEditor(currentKind);
});
async function api(path, opts = {}) {
const resp = await fetch(path, opts);
if (!resp.ok) {
const txt = await resp.text();
throw new Error("API error: " + txt);
}
if (resp.headers.get("Content-Type")?.includes("application/json")) {
return resp.json();
}
return null;
}
function renderTree(node) {
const ul = document.createElement("ul");
const mkEntryLi = (e) => {
const li = document.createElement("li");
li.textContent = "🔑 " + e.title;
li.className = "node";
li.onclick = () => {
selectedNode = { type: "entry", path: e.path, kind: e.kind };
currentPathEl.textContent = e.path;
kindSelect.value = e.kind;
showEditor(e.kind);
if (e.kind === "simple") {
document.getElementById("s-key").value = e.title;
document.getElementById("s-value").value = "";
} else {
document.getElementById("c-title").value = e.title;
document.getElementById("c-username").value = "";
document.getElementById("c-password").value = "";
document.getElementById("c-url").value = "";
document.getElementById("c-notes").value = "";
}
};
return li;
};
const mkGroupLi = (g) => {
const li = document.createElement("li");
const header = document.createElement("div");
header.textContent = "📂 " + (g.name || "/");
header.className = "node";
header.onclick = () => {
selectedNode = { type: "group", path: g.path };
currentPathEl.textContent = g.path;
};
li.appendChild(header);
const inner = renderTreeChildren(g);
li.appendChild(inner);
return li;
};
function renderTreeChildren(node) {
const wrap = document.createElement("ul");
node.groups.forEach(sg => wrap.appendChild(mkGroupLi(sg)));
node.entries.forEach(e => wrap.appendChild(mkEntryLi(e)));
return wrap;
}
ul.appendChild(mkGroupLi(node));
treeDiv.innerHTML = "";
treeDiv.appendChild(ul);
}
async function refreshTree() {
const data = await api("/api/v1/tree");
renderTree(data);
}
// Toolbar lewej kolumny
document.getElementById("btn-refresh").onclick = refreshTree;
document.getElementById("btn-add-group").onclick = async () => {
const name = prompt("Nazwa nowej grupy:");
if (!name) return;
await api("/api/v1/group", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ name, parent_path: selectedNode.type === "group" ? selectedNode.path : "/" })
});
await refreshTree();
};
document.getElementById("btn-add-entry").onclick = async () => {
const parent_path = selectedNode.type === "group" ? selectedNode.path : "/";
const kind = kindSelect.value;
if (kind === "simple") {
const key = prompt("Klucz (title):"); if (!key) return;
const value = prompt("Wartość (password):"); if (value === null) return;
await api("/api/v1/entry", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ kind, parent_path, data: { key, value } })
});
} else {
const title = prompt("Tytuł:"); if (!title) return;
await api("/api/v1/entry", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ kind, parent_path, data: { title, username:"", password:"", url:"", notes:"" } })
});
}
await refreshTree();
};
// Edytory
document.getElementById("s-save").onclick = async () => {
if (selectedNode.type !== "entry") { alert("Wybierz wpis"); return; }
const data = { key: document.getElementById("s-key").value, value: document.getElementById("s-value").value };
await api("/api/v1/entry", {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ path: selectedNode.path, kind: "simple", data })
});
await refreshTree();
};
document.getElementById("s-delete").onclick = async () => {
if (selectedNode.type !== "entry") return;
if (!confirm("Usunąć wpis?")) return;
await api("/api/v1/entry?path=" + encodeURIComponent(selectedNode.path), { method: "DELETE" });
await refreshTree();
};
document.getElementById("c-save").onclick = async () => {
if (selectedNode.type !== "entry") { alert("Wybierz wpis"); return; }
const data = {
title: document.getElementById("c-title").value,
username: document.getElementById("c-username").value,
password: document.getElementById("c-password").value,
url: document.getElementById("c-url").value,
notes: document.getElementById("c-notes").value
};
await api("/api/v1/entry", {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ path: selectedNode.path, kind: "complex", data })
});
await refreshTree();
};
document.getElementById("c-delete").onclick = async () => {
if (selectedNode.type !== "entry") return;
if (!confirm("Usunąć wpis?")) return;
await api("/api/v1/entry?path=" + encodeURIComponent(selectedNode.path), { method: "DELETE" });
await refreshTree();
};
document.getElementById("btn-move-entry").onclick = async () => {
if (selectedNode.type !== "entry") { alert("Wybierz wpis"); return; }
const target = document.getElementById("target-group").value || "/";
await api("/api/v1/entry/move", {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ path: selectedNode.path, target_group_path: target })
});
await refreshTree();
};
document.getElementById("btn-save-all").onclick = async () => {
await api("/api/v1/save", { method: "POST" });
alert("Zapisano do bazy");
};
// Start
showEditor(currentKind);
refreshTree().catch(err => console.error(err));

View file

@ -4,36 +4,63 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>{{ title }}</title> <title>{{ title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<style> <link rel="stylesheet" href="/static/css/style.css"/>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
margin: 2rem;
}
code {
background: #f4f4f4;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
a {
color: #0b5fff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head> </head>
<body> <body>
<h1>{{ title }}</h1> <div class="layout">
<p>To jest prosta strona Jinja2 serwowana przez FastAPI.</p> <div class="left">
<ul> <div class="toolbar">
<li>UI OpenAPI: <a href="/docs">/docs</a></li> <button id="btn-refresh">Odśwież</button>
<li>OpenAPI JSON: <a href="/openapi.json">/openapi.json</a></li> <button id="btn-add-group">Nowa grupa</button>
<li>API v1: <a href="/api/v1/">/api/v1/</a></li> <button id="btn-add-entry">Nowy wpis</button>
</ul> </div>
<p>Uruchamianie serwera lokalnie: <code>uv run app</code> lub <code>uv run uvicorn app.main:app --reload</code></p> <div id="tree"></div>
</div>
<div class="right">
<div class="toolbar">
<select id="entry-kind">
<option value="simple">Prosty (klucz:wartość)</option>
<option value="complex">Złożony</option>
</select>
<button id="btn-save-all">Zapisz do bazy</button>
</div>
<div class="section" id="editor-simple" style="display:none;">
<div class="row"><label class="mono" style="width:140px;">Klucz</label><input id="s-key" type="text"/></div>
<div class="row"><label class="mono" style="width:140px;">Wartość</label><input id="s-value"
type="password"/></div>
<div class="row" style="justify-content:flex-end; gap:8px;">
<button id="s-save">Zapisz wpis</button>
<button id="s-delete" class="danger">Usuń</button>
</div>
</div>
<div class="section" id="editor-complex" style="display:none;">
<div class="row"><label style="width:140px;">Tytuł</label><input id="c-title" type="text"/></div>
<div class="row"><label style="width:140px;">Użytkownik</label><input id="c-username" type="text"/></div>
<div class="row"><label style="width:140px;">Hasło</label><input id="c-password" type="password"/></div>
<div class="row"><label style="width:140px;">URL</label><input id="c-url" type="url"/></div>
<div class="row"><label style="width:140px;">Notatka</label><textarea id="c-notes" rows="5"></textarea>
</div>
<div class="row" style="justify-content:flex-end; gap:8px;">
<button id="c-save">Zapisz wpis</button>
<button id="c-delete" class="danger">Usuń</button>
</div>
</div>
<div class="section">
<div class="row">
<label style="width:140px;">Docelowa grupa</label>
<input id="target-group" type="text" placeholder="/Ścieżka/Grupy"/>
<button id="btn-move-entry">Przenieś wpis</button>
</div>
</div>
<div class="section">
Aktualna ścieżka: <span id="current-path" class="mono">/</span>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body> </body>
</html> </html>

3
app/web/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from .passwords import PasswordsController
__all__ = ["PasswordsController"]

128
app/web/passwords.py Normal file
View file

@ -0,0 +1,128 @@
from typing import Optional, Annotated
from injectable import autowired, injectable, Autowired
from pykeepass import Group, Entry
from app.api.models import EntryKind, EntrySimpleDTO, EntryComplexDTO, GroupDTO, NodeDTO, EntryNodeDTO
from app.services import Passwords
@injectable(singleton=True)
class PasswordsController:
@autowired
def __init__(self, passwords: Annotated[Passwords, Autowired]):
self._pw = passwords
# Helpers
def _group_by_path(self, path: Optional[str]) -> Group:
if not path or path == "/":
return self._pw.kp.root_group
parts = [p for p in path.split("/") if p]
g = self._pw.kp.root_group
for p in parts:
g = next((x for x in g.subgroups if x.name == p), None)
if g is None:
raise ValueError(f"Group not found: {path}")
return g
def _ensure_group(self, parent: Group, name: str) -> Group:
g = next((x for x in parent.subgroups if x.name == name), None)
if g:
return g
return self._pw.kp.add_group(parent, name)
def _entry_by_path(self, path: str) -> Entry:
# path: /Group1/Sub/Title
parts = [p for p in path.split("/") if p]
if not parts:
raise ValueError("Invalid entry path")
title = parts[-1]
group_path = "/" + "/".join(parts[:-1]) if len(parts) > 1 else "/"
g = self._group_by_path(group_path)
e = next((x for x in g.entries if x.title == title), None)
if not e:
raise ValueError(f"Entry not found: {path}")
return e
def _node_of_group(self, g: Group) -> NodeDTO:
raw_path = g.path
path = "/" + "/".join(raw_path) if raw_path else "/"
groups = [self._node_of_group(sg) for sg in g.subgroups]
entries = [EntryNodeDTO(
kind=EntryKind.complex if (e.username or e.url or e.notes) else EntryKind.simple,
title=e.title,
path=path.rstrip("/") + "/" + e.title
) for e in g.entries]
return NodeDTO(name=g.name or "", path=path, groups=groups, entries=entries)
# Tree
def get_tree(self) -> NodeDTO:
return self._node_of_group(self._pw.kp.root_group)
# Groups
def create_group(self, name: str, parent_path: Optional[str]) -> GroupDTO:
parent = self._group_by_path(parent_path)
g = self._ensure_group(parent, name)
return GroupDTO(name=g.name, path=self._node_of_group(g).path)
def rename_group(self, path: str, new_name: str) -> GroupDTO:
g = self._group_by_path(path)
g.name = new_name
return GroupDTO(name=g.name, path=self._node_of_group(g).path)
def delete_group(self, path: str) -> None:
g = self._group_by_path(path)
if g is self._pw.kp.root_group:
raise ValueError("Cannot delete root group")
self._pw.kp.delete_group(g)
# Entries
def create_entry_simple(self, parent_path: str, dto: EntrySimpleDTO) -> NodeDTO:
parent = self._group_by_path(parent_path)
title = dto.key
password = dto.value
self._pw.kp.add_entry(parent, title=title, username="", password=password)
return self._node_of_group(parent)
def create_entry_complex(self, parent_path: str, dto: EntryComplexDTO) -> NodeDTO:
parent = self._group_by_path(parent_path)
self._pw.kp.add_entry(
parent,
title=dto.title,
username=dto.username or "",
password=dto.password or "",
url=dto.url or "",
notes=dto.notes or "",
)
return self._node_of_group(parent)
def update_entry_simple(self, path: str, dto: EntrySimpleDTO) -> NodeDTO:
e = self._entry_by_path(path)
parent = e.group
e.title = dto.key
e.password = dto.value
return self._node_of_group(parent)
def update_entry_complex(self, path: str, dto: EntryComplexDTO) -> NodeDTO:
e = self._entry_by_path(path)
parent = e.group
e.title = dto.title
e.username = dto.username
e.password = dto.password
e.url = dto.url or ""
e.notes = dto.notes or ""
return self._node_of_group(parent)
def move_entry(self, path: str, target_group_path: str) -> NodeDTO:
e = self._entry_by_path(path)
target = self._group_by_path(target_group_path)
self._pw.kp.move_entry(e, target)
return self._node_of_group(target)
def delete_entry(self, path: str) -> None:
e = self._entry_by_path(path)
self._pw.kp.delete_entry(e)
# Persist
def save(self):
self._pw.save()

View file

@ -7,6 +7,7 @@ requires-python = ">=3.12"
authors = [{ name = "Piotr Dec" }] authors = [{ name = "Piotr Dec" }]
dependencies = [ dependencies = [
"fastapi>=0.119.0", "fastapi>=0.119.0",
"fastapi-utils>=0.8.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"jinja2>=3.1.4", "jinja2>=3.1.4",
"pydantic-settings>=2.4.0", "pydantic-settings>=2.4.0",