2025-12-11 12:47:30 -08:00
from __future__ import annotations
from typing import Any , Dict , List , Sequence , Optional
from pathlib import Path
import sys
2025-12-11 19:04:02 -08:00
from SYS . logger import log
2025-12-11 12:47:30 -08:00
import models
import pipeline as ctx
from . _shared import normalize_result_input , filter_results_by_temp
2025-12-11 19:04:02 -08:00
from API import HydrusNetwork as hydrus_wrapper
from API . folder import write_sidecar , API_folder_store
2025-12-11 12:47:30 -08:00
from . _shared import Cmdlet , CmdletArg , SharedArgs , normalize_hash , parse_tag_arguments , expand_tag_groups , parse_cmdlet_args , collapse_namespace_tags , should_show_help , get_field
from config import get_local_storage_path
class Add_Tag ( Cmdlet ) :
""" Class-based add-tag cmdlet with Cmdlet metadata inheritance. """
def __init__ ( self ) - > None :
super ( ) . __init__ (
name = " add-tag " ,
summary = " Add a tag to a Hydrus file or write it to a local .tags sidecar. " ,
usage = " add-tag [-hash <sha256>] [-store <backend>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...] " ,
arg = [
SharedArgs . HASH ,
SharedArgs . STORE ,
CmdletArg ( " -duplicate " , type = " string " , description = " Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred) " ) ,
CmdletArg ( " -list " , type = " string " , description = " Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult). " ) ,
CmdletArg ( " --all " , type = " flag " , description = " Include temporary files in tagging (by default, only tags non-temporary files). " ) ,
CmdletArg ( " tags " , type = " string " , required = False , description = " One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload. " , variadic = True ) ,
] ,
detail = [
" - By default, only tags non-temporary files (from pipelines). Use --all to tag everything. " ,
" - Without -hash and when the selection is a local file, tags are written to <file>.tags. " ,
" - With a Hydrus hash, tags are sent to the ' my tags ' service. " ,
" - Multiple tags can be comma-separated or space-separated. " ,
" - Use -list to include predefined tag lists from adjective.json: -list philosophy,occult " ,
" - Tags can also reference lists with curly braces: add-tag {philosophy} \" other:tag \" " ,
" - Use -duplicate to copy EXISTING tag values to new namespaces: " ,
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:) " ,
" Inferred format: -duplicate title,album,artist (first is source, rest are targets) " ,
" - The source namespace must already exist in the file being tagged. " ,
" - Target namespaces that already have a value are skipped (not overwritten). " ,
" - You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list. " ,
] ,
exec = self . run ,
)
self . register ( )
@staticmethod
def _extract_title_tag ( tags : List [ str ] ) - > Optional [ str ] :
""" Return the value of the first title: tag if present. """
for tag in tags :
if isinstance ( tag , str ) and tag . lower ( ) . startswith ( " title: " ) :
value = tag . split ( " : " , 1 ) [ 1 ] . strip ( )
if value :
return value
return None
@staticmethod
def _apply_title_to_result ( res : Any , title_value : Optional [ str ] ) - > None :
""" Update result object/dict title fields and columns in-place. """
if not title_value :
return
if isinstance ( res , models . PipeObject ) :
res . title = title_value
if hasattr ( res , " columns " ) and isinstance ( res . columns , list ) and res . columns :
label , * _ = res . columns [ 0 ]
if str ( label ) . lower ( ) == " title " :
res . columns [ 0 ] = ( res . columns [ 0 ] [ 0 ] , title_value )
elif isinstance ( res , dict ) :
res [ " title " ] = title_value
cols = res . get ( " columns " )
if isinstance ( cols , list ) :
updated = [ ]
changed = False
for col in cols :
if isinstance ( col , tuple ) and len ( col ) == 2 :
label , val = col
if str ( label ) . lower ( ) == " title " :
updated . append ( ( label , title_value ) )
changed = True
else :
updated . append ( col )
else :
updated . append ( col )
if changed :
res [ " columns " ] = updated
@staticmethod
2025-12-11 19:04:02 -08:00
def _matches_target ( item : Any , file_hash : Optional [ str ] , path : Optional [ str ] ) - > bool :
2025-12-11 12:47:30 -08:00
""" Determine whether a result item refers to the given hash/path target. """
file_hash_l = file_hash . lower ( ) if file_hash else None
2025-12-11 19:04:02 -08:00
path_l = path . lower ( ) if path else None
2025-12-11 12:47:30 -08:00
def norm ( val : Any ) - > Optional [ str ] :
return str ( val ) . lower ( ) if val is not None else None
2025-12-11 19:04:02 -08:00
hash_fields = [ " hash " ]
path_fields = [ " path " , " target " ]
2025-12-11 12:47:30 -08:00
if isinstance ( item , dict ) :
hashes = [ norm ( item . get ( field ) ) for field in hash_fields ]
paths = [ norm ( item . get ( field ) ) for field in path_fields ]
else :
hashes = [ norm ( get_field ( item , field ) ) for field in hash_fields ]
paths = [ norm ( get_field ( item , field ) ) for field in path_fields ]
if file_hash_l and file_hash_l in hashes :
return True
2025-12-11 19:04:02 -08:00
if path_l and path_l in paths :
2025-12-11 12:47:30 -08:00
return True
return False
@staticmethod
def _update_item_title_fields ( item : Any , new_title : str ) - > None :
""" Mutate an item to reflect a new title in plain fields and columns. """
if isinstance ( item , models . PipeObject ) :
item . title = new_title
if hasattr ( item , " columns " ) and isinstance ( item . columns , list ) and item . columns :
label , * _ = item . columns [ 0 ]
if str ( label ) . lower ( ) == " title " :
item . columns [ 0 ] = ( label , new_title )
elif isinstance ( item , dict ) :
item [ " title " ] = new_title
cols = item . get ( " columns " )
if isinstance ( cols , list ) :
updated_cols = [ ]
changed = False
for col in cols :
if isinstance ( col , tuple ) and len ( col ) == 2 :
label , val = col
if str ( label ) . lower ( ) == " title " :
updated_cols . append ( ( label , new_title ) )
changed = True
else :
updated_cols . append ( col )
else :
updated_cols . append ( col )
if changed :
item [ " columns " ] = updated_cols
2025-12-11 19:04:02 -08:00
def _refresh_result_table_title ( self , new_title : str , file_hash : Optional [ str ] , path : Optional [ str ] ) - > None :
2025-12-11 12:47:30 -08:00
""" Refresh the cached result table with an updated title and redisplay it. """
try :
last_table = ctx . get_last_result_table ( )
items = ctx . get_last_result_items ( )
if not last_table or not items :
return
updated_items = [ ]
match_found = False
for item in items :
try :
2025-12-11 19:04:02 -08:00
if self . _matches_target ( item , file_hash , path ) :
2025-12-11 12:47:30 -08:00
self . _update_item_title_fields ( item , new_title )
match_found = True
except Exception :
pass
updated_items . append ( item )
if not match_found :
return
from result_table import ResultTable # Local import to avoid circular dependency
new_table = last_table . copy_with_title ( getattr ( last_table , " title " , " " ) )
for item in updated_items :
new_table . add_result ( item )
ctx . set_last_result_table_overlay ( new_table , updated_items )
except Exception :
pass
2025-12-11 19:04:02 -08:00
def _refresh_tags_view ( self , res : Any , file_hash : Optional [ str ] , path : Optional [ str ] , config : Dict [ str , Any ] ) - > None :
2025-12-11 12:47:30 -08:00
""" Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh. """
try :
from cmdlets import get_tag as get_tag_cmd # type: ignore
except Exception :
return
2025-12-11 19:04:02 -08:00
target_hash = file_hash
2025-12-11 12:47:30 -08:00
refresh_args : List [ str ] = [ ]
if target_hash :
2025-12-11 19:04:02 -08:00
refresh_args = [ " -hash " , target_hash ]
2025-12-11 12:47:30 -08:00
try :
subject = ctx . get_last_result_subject ( )
2025-12-11 19:04:02 -08:00
if subject and self . _matches_target ( subject , file_hash , path ) :
2025-12-11 12:47:30 -08:00
get_tag_cmd . _run ( subject , refresh_args , config )
return
except Exception :
pass
if target_hash :
try :
get_tag_cmd . _run ( res , refresh_args , config )
except Exception :
pass
def run ( self , result : Any , args : Sequence [ str ] , config : Dict [ str , Any ] ) - > int :
""" Add a tag to a file with smart filtering for pipeline results. """
if should_show_help ( args ) :
log ( f " Cmdlet: { self . name } \n Summary: { self . summary } \n Usage: { self . usage } " )
return 0
parsed = parse_cmdlet_args ( args , self )
# Check for --all flag
include_temp = parsed . get ( " all " , False )
# Get explicit -hash and -store overrides from CLI
hash_override = normalize_hash ( parsed . get ( " hash " ) )
2025-12-11 19:04:02 -08:00
store_override = parsed . get ( " store " )
2025-12-11 12:47:30 -08:00
# Normalize input to list
results = normalize_result_input ( result )
# If no piped results but we have -hash flag, create a minimal synthetic result
if not results and hash_override :
results = [ { " hash " : hash_override , " is_temp " : False } ]
if store_override :
results [ 0 ] [ " store " ] = store_override
# Filter by temp status (unless --all is set)
if not include_temp :
results = filter_results_by_temp ( results , include_temp = False )
if not results :
log ( " No valid files to tag (all results were temporary; use --all to include temporary files) " , file = sys . stderr )
return 1
# Get tags from arguments (or fallback to pipeline payload)
raw_tags = parsed . get ( " tags " , [ ] )
if isinstance ( raw_tags , str ) :
raw_tags = [ raw_tags ]
# Fallback: if no tags provided explicitly, try to pull from first result payload
if not raw_tags and results :
first = results [ 0 ]
payload_tags = None
# Try multiple tag lookup strategies in order
tag_lookups = [
lambda x : x . extra . get ( " tags " ) if isinstance ( x , models . PipeObject ) and isinstance ( x . extra , dict ) else None ,
lambda x : x . get ( " tags " ) if isinstance ( x , dict ) else None ,
lambda x : x . get ( " extra " , { } ) . get ( " tags " ) if isinstance ( x , dict ) and isinstance ( x . get ( " extra " ) , dict ) else None ,
lambda x : getattr ( x , " tags " , None ) ,
]
for lookup in tag_lookups :
try :
payload_tags = lookup ( first )
if payload_tags :
break
except ( AttributeError , TypeError , KeyError ) :
continue
if payload_tags :
if isinstance ( payload_tags , str ) :
raw_tags = [ payload_tags ]
elif isinstance ( payload_tags , list ) :
raw_tags = payload_tags
# Handle -list argument (convert to {list} syntax)
list_arg = parsed . get ( " list " )
if list_arg :
for l in list_arg . split ( ' , ' ) :
l = l . strip ( )
if l :
raw_tags . append ( f " {{ { l } }} " )
# Parse and expand tags
tags_to_add = parse_tag_arguments ( raw_tags )
tags_to_add = expand_tag_groups ( tags_to_add )
# Allow hash override via namespaced token (e.g., "hash:abcdef...")
extracted_hash = None
filtered_tags : List [ str ] = [ ]
for tag in tags_to_add :
if isinstance ( tag , str ) and tag . lower ( ) . startswith ( " hash: " ) :
_ , _ , hash_val = tag . partition ( " : " )
if hash_val :
extracted_hash = normalize_hash ( hash_val . strip ( ) )
continue
filtered_tags . append ( tag )
tags_to_add = filtered_tags
if not tags_to_add :
log ( " No tags provided to add " , file = sys . stderr )
return 1
def _find_library_root ( path_obj : Path ) - > Optional [ Path ] :
candidates = [ ]
cfg_root = get_local_storage_path ( config ) if config else None
if cfg_root :
try :
candidates . append ( Path ( cfg_root ) . expanduser ( ) )
except Exception :
pass
try :
for candidate in candidates :
if ( candidate / " medios-macina.db " ) . exists ( ) :
return candidate
for parent in [ path_obj ] + list ( path_obj . parents ) :
if ( parent / " medios-macina.db " ) . exists ( ) :
return parent
except Exception :
pass
return None
# Get other flags
duplicate_arg = parsed . get ( " duplicate " )
if not tags_to_add and not duplicate_arg :
# Write sidecar files with the tags that are already in the result dicts
sidecar_count = 0
for res in results :
# Handle both dict and PipeObject formats
file_path = None
tags = [ ]
file_hash = " "
# Use canonical field access with get_field for both dict and objects
file_path = get_field ( res , " path " )
# Try tags from top-level 'tags' or from 'extra.tags'
tags = get_field ( res , " tags " ) or ( get_field ( res , " extra " ) or { } ) . get ( " tags " , [ ] )
2025-12-11 19:04:02 -08:00
file_hash = get_field ( res , " hash " ) or " "
2025-12-11 12:47:30 -08:00
if not file_path :
log ( f " [add_tag] Warning: Result has no path, skipping " , file = sys . stderr )
ctx . emit ( res )
continue
if tags :
# Write sidecar file for this file with its tags
try :
sidecar_path = write_sidecar ( Path ( file_path ) , tags , [ ] , file_hash )
log ( f " [add_tag] Wrote { len ( tags ) } tag(s) to sidecar: { sidecar_path } " , file = sys . stderr )
sidecar_count + = 1
except Exception as e :
log ( f " [add_tag] Warning: Failed to write sidecar for { file_path } : { e } " , file = sys . stderr )
ctx . emit ( res )
if sidecar_count > 0 :
log ( f " [add_tag] Wrote { sidecar_count } sidecar file(s) with embedded tags " , file = sys . stderr )
else :
log ( f " [add_tag] No tags to write - passed { len ( results ) } result(s) through unchanged " , file = sys . stderr )
return 0
# Main loop: process results with tags to add
total_new_tags = 0
total_modified = 0
for res in results :
# Extract file info from result
file_path = None
existing_tags = [ ]
file_hash = " "
storage_source = None
# Use canonical getters for fields from both dicts and PipeObject
file_path = get_field ( res , " path " )
existing_tags = get_field ( res , " tags " ) or [ ]
if not existing_tags :
existing_tags = ( get_field ( res , " extra " , { } ) or { } ) . get ( " tags " ) or [ ]
2025-12-11 19:04:02 -08:00
file_hash = get_field ( res , " hash " ) or " "
store_name = store_override or get_field ( res , " store " )
2025-12-11 12:47:30 -08:00
original_tags_lower = { str ( t ) . lower ( ) for t in existing_tags if isinstance ( t , str ) }
original_title = self . _extract_title_tag ( list ( existing_tags ) )
# Apply CLI overrides if provided
if hash_override and not file_hash :
file_hash = hash_override
2025-12-11 19:04:02 -08:00
if not store_name :
log ( " [add_tag] Missing store (use -store or pipe a result with store) " , file = sys . stderr )
ctx . emit ( res )
continue
2025-12-11 12:47:30 -08:00
# Check if we have sufficient identifier (file_path OR file_hash)
if not file_path and not file_hash :
log ( f " [add_tag] Warning: Result has neither path nor hash available, skipping " , file = sys . stderr )
ctx . emit ( res )
continue
# Handle -duplicate logic (copy existing tags to new namespaces)
if duplicate_arg :
# Parse duplicate format: source:target1,target2 or source,target1,target2
parts = duplicate_arg . split ( ' : ' )
source_ns = " "
targets = [ ]
if len ( parts ) > 1 :
# Explicit format: source:target1,target2
source_ns = parts [ 0 ]
targets = parts [ 1 ] . split ( ' , ' )
else :
# Inferred format: source,target1,target2
parts = duplicate_arg . split ( ' , ' )
if len ( parts ) > 1 :
source_ns = parts [ 0 ]
targets = parts [ 1 : ]
if source_ns and targets :
# Find tags in source namespace
source_tags = [ t for t in existing_tags if t . startswith ( source_ns + ' : ' ) ]
for t in source_tags :
value = t . split ( ' : ' , 1 ) [ 1 ]
for target_ns in targets :
new_tag = f " { target_ns } : { value } "
if new_tag not in existing_tags and new_tag not in tags_to_add :
tags_to_add . append ( new_tag )
# Initialize tag mutation tracking local variables
removed_tags = [ ]
new_tags_added = [ ]
final_tags = list ( existing_tags ) if existing_tags else [ ]
2025-12-11 19:04:02 -08:00
# Resolve hash from path if needed
if not file_hash and file_path :
2025-12-11 12:47:30 -08:00
try :
2025-12-11 19:04:02 -08:00
from SYS . utils import sha256_file
file_hash = sha256_file ( Path ( file_path ) )
except Exception :
file_hash = " "
if not file_hash :
log ( " [add_tag] Warning: No hash available, skipping " , file = sys . stderr )
2025-12-11 12:47:30 -08:00
ctx . emit ( res )
continue
2025-12-11 19:04:02 -08:00
# Route tag updates through the configured store backend
try :
storage = Store ( config )
backend = storage [ store_name ]
# For namespaced tags, compute old tags in same namespace to remove
removed_tags = [ ]
for new_tag in tags_to_add :
if ' : ' in new_tag :
namespace = new_tag . split ( ' : ' , 1 ) [ 0 ]
to_remove = [ t for t in existing_tags if t . startswith ( namespace + ' : ' ) and t . lower ( ) != new_tag . lower ( ) ]
removed_tags . extend ( to_remove )
ok = backend . add_tag ( file_hash , tags_to_add , config = config )
if removed_tags :
unique_removed = sorted ( set ( removed_tags ) )
backend . delete_tag ( file_hash , unique_removed , config = config )
if not ok :
log ( f " [add_tag] Warning: Failed to add tags via store ' { store_name } ' " , file = sys . stderr )
ctx . emit ( res )
continue
refreshed_tags , _ = backend . get_tag ( file_hash , config = config )
refreshed_tags = list ( refreshed_tags or [ ] )
final_tags = refreshed_tags
new_tags_added = [ t for t in refreshed_tags if t . lower ( ) not in original_tags_lower ]
# Update result tags for downstream cmdlets/UI
if isinstance ( res , models . PipeObject ) :
res . tags = refreshed_tags
if isinstance ( res . extra , dict ) :
res . extra [ ' tags ' ] = refreshed_tags
elif isinstance ( res , dict ) :
res [ ' tags ' ] = refreshed_tags
# Update title if changed
title_value = self . _extract_title_tag ( refreshed_tags )
self . _apply_title_to_result ( res , title_value )
total_new_tags + = len ( new_tags_added )
if new_tags_added :
total_modified + = 1
except KeyError :
log ( f " [add_tag] Store ' { store_name } ' not configured " , file = sys . stderr )
ctx . emit ( res )
continue
except Exception as e :
log ( f " [add_tag] Warning: Backend error for store ' { store_name } ' : { e } " , file = sys . stderr )
ctx . emit ( res )
continue
2025-12-11 12:47:30 -08:00
# If title changed, refresh the cached result table so the display reflects the new name
final_title = self . _extract_title_tag ( final_tags )
if final_title and ( not original_title or final_title . lower ( ) != original_title . lower ( ) ) :
2025-12-11 19:04:02 -08:00
self . _refresh_result_table_title ( final_title , file_hash , file_path )
# If tags changed, refresh tag view via get-tag
2025-12-11 12:47:30 -08:00
if new_tags_added or removed_tags :
2025-12-11 19:04:02 -08:00
self . _refresh_tags_view ( res , file_hash , file_path , config )
2025-12-11 12:47:30 -08:00
# Emit the modified result
ctx . emit ( res )
log ( f " [add_tag] Added { total_new_tags } new tag(s) across { len ( results ) } item(s); modified { total_modified } item(s) " , file = sys . stderr )
return 0
CMDLET = Add_Tag ( )