d
This commit is contained in:
@@ -10,11 +10,65 @@ import sys
|
||||
from SYS.logger import log
|
||||
|
||||
import pipeline as ctx
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, get_field, should_show_help
|
||||
from API.folder import LocalLibrarySearchOptimizer
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, normalize_hash, normalize_result_input, get_field, should_show_help
|
||||
from API.folder import API_folder_store
|
||||
from Store import Store
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
def _extract_hash(item: Any) -> Optional[str]:
|
||||
h = get_field(item, "hash_hex") or get_field(item, "hash") or get_field(item, "file_hash")
|
||||
return normalize_hash(str(h)) if h else None
|
||||
|
||||
|
||||
def _upsert_relationships(db: API_folder_store, file_hash: str, relationships: Dict[str, Any]) -> None:
|
||||
conn = db.connection
|
||||
if conn is None:
|
||||
raise RuntimeError("Store DB connection is not initialized")
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO metadata (hash, relationships)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET
|
||||
relationships = excluded.relationships,
|
||||
time_modified = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(file_hash, json.dumps(relationships) if relationships else "{}"),
|
||||
)
|
||||
|
||||
|
||||
def _remove_reverse_link(db: API_folder_store, *, src_hash: str, dst_hash: str, rel_type: str) -> None:
|
||||
meta = db.get_metadata(dst_hash) or {}
|
||||
rels = meta.get("relationships") if isinstance(meta, dict) else None
|
||||
if not isinstance(rels, dict) or not rels:
|
||||
return
|
||||
|
||||
key_to_edit: Optional[str] = None
|
||||
for k in list(rels.keys()):
|
||||
if str(k).lower() == str(rel_type).lower():
|
||||
key_to_edit = str(k)
|
||||
break
|
||||
if not key_to_edit:
|
||||
return
|
||||
|
||||
bucket = rels.get(key_to_edit)
|
||||
if not isinstance(bucket, list) or not bucket:
|
||||
return
|
||||
|
||||
new_bucket = [h for h in bucket if str(h).lower() != str(src_hash).lower()]
|
||||
if new_bucket:
|
||||
rels[key_to_edit] = new_bucket
|
||||
else:
|
||||
try:
|
||||
del rels[key_to_edit]
|
||||
except Exception:
|
||||
rels[key_to_edit] = []
|
||||
|
||||
_upsert_relationships(db, dst_hash, rels)
|
||||
|
||||
|
||||
def _refresh_relationship_view_if_current(target_hash: Optional[str], target_path: Optional[str], other: Optional[str], config: Dict[str, Any]) -> None:
|
||||
"""If the current subject matches the target, refresh relationships via get-relationship."""
|
||||
try:
|
||||
@@ -84,109 +138,223 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
parsed_args = parse_cmdlet_args(args, CMDLET)
|
||||
delete_all_flag = parsed_args.get("all", False)
|
||||
rel_type_filter = parsed_args.get("type")
|
||||
override_store = parsed_args.get("store")
|
||||
override_hash = parsed_args.get("hash")
|
||||
raw_path = parsed_args.get("path")
|
||||
|
||||
# Normalize input
|
||||
results = normalize_result_input(result)
|
||||
|
||||
# Allow store/hash-first usage when no pipeline items were provided
|
||||
if (not results) and override_hash:
|
||||
raw = str(override_hash)
|
||||
parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
|
||||
hashes = [h for h in (normalize_hash(p) for p in parts) if h]
|
||||
if not hashes:
|
||||
log("Invalid -hash value (expected 64-hex sha256)", file=sys.stderr)
|
||||
return 1
|
||||
if not override_store:
|
||||
log("-store is required when using -hash without piped items", file=sys.stderr)
|
||||
return 1
|
||||
results = [{"hash": h, "store": str(override_store)} for h in hashes]
|
||||
|
||||
if not results:
|
||||
# Legacy -path mode below may still apply
|
||||
if raw_path:
|
||||
results = [{"file_path": raw_path}]
|
||||
else:
|
||||
log("No results to process", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Decide store (for same-store enforcement + folder-store DB routing)
|
||||
store_name: Optional[str] = str(override_store).strip() if override_store else None
|
||||
if not store_name:
|
||||
stores = {str(get_field(r, "store")) for r in results if get_field(r, "store")}
|
||||
if len(stores) == 1:
|
||||
store_name = next(iter(stores))
|
||||
elif len(stores) > 1:
|
||||
log("Multiple stores detected in pipeline; use -store to choose one", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
# STORE/HASH FIRST: folder-store DB deletion (preferred)
|
||||
if store_name:
|
||||
backend = None
|
||||
store_root: Optional[Path] = None
|
||||
try:
|
||||
store = Store(config)
|
||||
backend = store[str(store_name)]
|
||||
loc = getattr(backend, "location", None)
|
||||
if callable(loc):
|
||||
store_root = Path(str(loc()))
|
||||
except Exception:
|
||||
backend = None
|
||||
store_root = None
|
||||
|
||||
if store_root is not None:
|
||||
try:
|
||||
with API_folder_store(store_root) as db:
|
||||
conn = db.connection
|
||||
if conn is None:
|
||||
raise RuntimeError("Store DB connection is not initialized")
|
||||
for single_result in results:
|
||||
# Enforce same-store when items carry store info
|
||||
item_store = get_field(single_result, "store")
|
||||
if item_store and str(item_store) != str(store_name):
|
||||
log(f"Cross-store delete blocked: item store '{item_store}' != '{store_name}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_hash = _extract_hash(single_result)
|
||||
if not file_hash:
|
||||
# Try path -> hash lookup within this store
|
||||
fp = (
|
||||
get_field(single_result, "file_path")
|
||||
or get_field(single_result, "path")
|
||||
or get_field(single_result, "target")
|
||||
)
|
||||
if fp:
|
||||
try:
|
||||
file_hash = db.get_file_hash(Path(str(fp)))
|
||||
except Exception:
|
||||
file_hash = None
|
||||
if not file_hash:
|
||||
log("Could not extract file hash for deletion (use -hash or ensure pipeline includes hash)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
meta = db.get_metadata(file_hash) or {}
|
||||
rels = meta.get("relationships") if isinstance(meta, dict) else None
|
||||
if not isinstance(rels, dict) or not rels:
|
||||
continue
|
||||
|
||||
if delete_all_flag:
|
||||
# remove reverse edges for all types
|
||||
for rt, hashes in list(rels.items()):
|
||||
if not isinstance(hashes, list):
|
||||
continue
|
||||
for other_hash in hashes:
|
||||
other_norm = normalize_hash(str(other_hash))
|
||||
if other_norm:
|
||||
_remove_reverse_link(db, src_hash=file_hash, dst_hash=other_norm, rel_type=str(rt))
|
||||
rels = {}
|
||||
elif rel_type_filter:
|
||||
# delete one type (case-insensitive key match)
|
||||
key_to_delete: Optional[str] = None
|
||||
for k in list(rels.keys()):
|
||||
if str(k).lower() == str(rel_type_filter).lower():
|
||||
key_to_delete = str(k)
|
||||
break
|
||||
if not key_to_delete:
|
||||
continue
|
||||
hashes = rels.get(key_to_delete)
|
||||
if isinstance(hashes, list):
|
||||
for other_hash in hashes:
|
||||
other_norm = normalize_hash(str(other_hash))
|
||||
if other_norm:
|
||||
_remove_reverse_link(db, src_hash=file_hash, dst_hash=other_norm, rel_type=str(key_to_delete))
|
||||
try:
|
||||
del rels[key_to_delete]
|
||||
except Exception:
|
||||
rels[key_to_delete] = []
|
||||
else:
|
||||
log("Specify --all to delete all relationships or -type <type> to delete specific type", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
_upsert_relationships(db, file_hash, rels)
|
||||
conn.commit()
|
||||
_refresh_relationship_view_if_current(file_hash, None, None, config)
|
||||
deleted_count += 1
|
||||
|
||||
log(f"Successfully deleted relationships from {deleted_count} file(s)", file=sys.stderr)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log(f"Error deleting store relationships: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# LEGACY PATH MODE (single local DB)
|
||||
# Get storage path
|
||||
local_storage_path = get_local_storage_path(config)
|
||||
if not local_storage_path:
|
||||
log("Local storage path not configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Normalize input
|
||||
results = normalize_result_input(result)
|
||||
|
||||
if not results:
|
||||
log("No results to process", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for single_result in results:
|
||||
try:
|
||||
# Get file path from result
|
||||
file_path_from_result = (
|
||||
get_field(single_result, "file_path")
|
||||
or get_field(single_result, "path")
|
||||
or get_field(single_result, "target")
|
||||
or (str(single_result) if not isinstance(single_result, dict) else None)
|
||||
)
|
||||
|
||||
if not file_path_from_result:
|
||||
log("Could not extract file path from result", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_path_obj = Path(str(file_path_from_result))
|
||||
|
||||
if not file_path_obj.exists():
|
||||
log(f"File not found: {file_path_obj}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with LocalLibrarySearchOptimizer(local_storage_path) as db:
|
||||
file_id = db.db.get_file_id(file_path_obj)
|
||||
|
||||
if not file_id:
|
||||
|
||||
try:
|
||||
with API_folder_store(Path(local_storage_path)) as db:
|
||||
conn = db.connection
|
||||
if conn is None:
|
||||
raise RuntimeError("Store DB connection is not initialized")
|
||||
|
||||
for single_result in results:
|
||||
# Get file path from result
|
||||
file_path_from_result = (
|
||||
get_field(single_result, "file_path")
|
||||
or get_field(single_result, "path")
|
||||
or get_field(single_result, "target")
|
||||
or (str(single_result) if not isinstance(single_result, dict) else None)
|
||||
)
|
||||
|
||||
if not file_path_from_result:
|
||||
log("Could not extract file path from result", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_path_obj = Path(str(file_path_from_result))
|
||||
|
||||
if not file_path_obj.exists():
|
||||
log(f"File not found: {file_path_obj}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
file_hash = db.get_file_hash(file_path_obj)
|
||||
except Exception:
|
||||
file_hash = None
|
||||
file_hash = normalize_hash(str(file_hash)) if file_hash else None
|
||||
if not file_hash:
|
||||
log(f"File not in database: {file_path_obj.name}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Get current relationships
|
||||
cursor = db.db.connection.cursor()
|
||||
cursor.execute("""
|
||||
SELECT relationships FROM metadata WHERE file_id = ?
|
||||
""", (file_id,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
log(f"No relationships found for: {file_path_obj.name}", file=sys.stderr)
|
||||
|
||||
meta = db.get_metadata(file_hash) or {}
|
||||
rels = meta.get("relationships") if isinstance(meta, dict) else None
|
||||
if not isinstance(rels, dict) or not rels:
|
||||
continue
|
||||
|
||||
relationships_str = row[0]
|
||||
if not relationships_str:
|
||||
log(f"No relationships found for: {file_path_obj.name}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
try:
|
||||
relationships = json.loads(relationships_str)
|
||||
except json.JSONDecodeError:
|
||||
log(f"Invalid relationship data for: {file_path_obj.name}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
if not isinstance(relationships, dict):
|
||||
relationships = {}
|
||||
|
||||
# Determine what to delete
|
||||
|
||||
if delete_all_flag:
|
||||
# Delete all relationships
|
||||
deleted_types = list(relationships.keys())
|
||||
relationships = {}
|
||||
log(f"Deleted all relationships ({len(deleted_types)} types) from: {file_path_obj.name}", file=sys.stderr)
|
||||
for rt, hashes in list(rels.items()):
|
||||
if not isinstance(hashes, list):
|
||||
continue
|
||||
for other_hash in hashes:
|
||||
other_norm = normalize_hash(str(other_hash))
|
||||
if other_norm:
|
||||
_remove_reverse_link(db, src_hash=file_hash, dst_hash=other_norm, rel_type=str(rt))
|
||||
rels = {}
|
||||
elif rel_type_filter:
|
||||
# Delete specific type
|
||||
if rel_type_filter in relationships:
|
||||
deleted_count_for_type = len(relationships[rel_type_filter])
|
||||
del relationships[rel_type_filter]
|
||||
log(f"Deleted {deleted_count_for_type} {rel_type_filter} relationship(s) from: {file_path_obj.name}", file=sys.stderr)
|
||||
else:
|
||||
log(f"No {rel_type_filter} relationships found for: {file_path_obj.name}", file=sys.stderr)
|
||||
key_to_delete: Optional[str] = None
|
||||
for k in list(rels.keys()):
|
||||
if str(k).lower() == str(rel_type_filter).lower():
|
||||
key_to_delete = str(k)
|
||||
break
|
||||
if not key_to_delete:
|
||||
continue
|
||||
hashes = rels.get(key_to_delete)
|
||||
if isinstance(hashes, list):
|
||||
for other_hash in hashes:
|
||||
other_norm = normalize_hash(str(other_hash))
|
||||
if other_norm:
|
||||
_remove_reverse_link(db, src_hash=file_hash, dst_hash=other_norm, rel_type=str(key_to_delete))
|
||||
try:
|
||||
del rels[key_to_delete]
|
||||
except Exception:
|
||||
rels[key_to_delete] = []
|
||||
else:
|
||||
log("Specify --all to delete all relationships or -type <type> to delete specific type", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Save updated relationships
|
||||
cursor.execute("""
|
||||
INSERT INTO metadata (file_id, relationships)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(file_id) DO UPDATE SET
|
||||
relationships = excluded.relationships,
|
||||
time_modified = CURRENT_TIMESTAMP
|
||||
""", (file_id, json.dumps(relationships) if relationships else None))
|
||||
|
||||
db.db.connection.commit()
|
||||
_refresh_relationship_view_if_current(None, str(file_path_obj), None, config)
|
||||
|
||||
_upsert_relationships(db, file_hash, rels)
|
||||
conn.commit()
|
||||
_refresh_relationship_view_if_current(file_hash, str(file_path_obj), None, config)
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as exc:
|
||||
log(f"Error deleting relationship: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Error deleting relationship: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
log(f"Successfully deleted relationships from {deleted_count} file(s)", file=sys.stderr)
|
||||
return 0
|
||||
@@ -201,7 +369,9 @@ CMDLET = Cmdlet(
|
||||
summary="Remove relationships from files.",
|
||||
usage="@1 | delete-relationship --all OR delete-relationship -path <file> --all OR @1-3 | delete-relationship -type alt",
|
||||
arg=[
|
||||
CmdletArg("path", type="string", description="Specify the local file path (if not piping a result)."),
|
||||
CmdletArg("path", type="string", description="Specify the local file path (legacy mode, if not piping a result)."),
|
||||
SharedArgs.STORE,
|
||||
SharedArgs.HASH,
|
||||
CmdletArg("all", type="flag", description="Delete all relationships for the file(s)."),
|
||||
CmdletArg("type", type="string", description="Delete specific relationship type ('alt', 'king', 'related'). Default: delete all types."),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user