
    Iiw                         S r SSKrSSKrSSKrSSKrSSKrSSKrSSKrSSKrSSK	r	SSK
J
r
Jr  SSKJr  \R                  " S5      rSrSrSrS	rS
rSr " S S5      rg)a&  Document manager for loading, editing, and versioning server documents.

Supports a shared/independent directory split for pull-only sync infrastructure:
- ``shared/`` contains canonical documents tracked in the git repository.
- ``independent/`` contains server-specific documents (gitignored).

Three contribution modes (configured via ``config.toml``):
- **manual**: changes stay uncommitted; admin exports a ZIP.
- **auto_commit** (default): server auto-commits after each save.
- **auto_pr**: auto-commit + admin can create a PR via ``gh`` CLI.
    N)datetimetimezone)Pathzplaypalace.documents   sharedindependentmanualauto_commitauto_prc                   <   \ rS rSrSr\4S\S\4S jjrS\	4S jr
SXS	 jrS
\S\SS4S jrS\S\4S jrS\S\\   4S jrS\\S-  \	4   4S jrS\S-  S\S\\   4S jrS\S\S-  4S jrS\S\	4S jrS\S\S-  4S jrS\S\S\S-  4S jrS\S\S\S\S\4
S jr\4S\S\\   S\S\S\S\S\4S jjrS\S\S\S\4S jrS\S\S \S\4S! jrS\S\\   S\4S" jrS\S\S\S\S\4
S# jrS$S%.S\S\S&\S\4S' jjrS\S\4S( jr S)\S*\S\S\4S+ jr!S)\S\4S, jr"S)\S*\S\S\4S- jr#S)\S.\S\4S/ jr$S)\S\4S0 jr%\&S\S\4S1 j5       r'S\S\S2\S\S-  4S3 jr(S\S\S2\SS4S4 jr)S\S\S\S-  4S5 jr*S\S\\\4   4S6 jr+SYS7\	SS4S8 jjr, SZS\S\S\S9\S:\SS4S; jjr-S\\   4S< jr.S=\\   SS4S> jr/S\\   4S? jr0S\S\S\S:\S\1\\4   4
S@ jr2S\\   4SA jr3SB\S\\   4SC jr4SB\S\\   4SD jr5S\	4SE jr6S\\   4SF jr7S\S\4SG jr8SH\S\	4SI jr9S\1\\4   4SJ jr:SXSK jr;S\1\\4   4SL jr<S\S-  4SM jr=S\S\4SN jr>\&S\S\4SO j5       r?S\SP\S\S\4SQ jr@S\S\S-  4SR jrAS\S\S-  4SS jrBSXST jrCS\SS4SU jrDS\S\SS4SV jrESWrFg)[DocumentManager%   a  Manages document metadata, content, edit locks, and version history.

Documents live in a directory tree split into ``shared/`` (git-tracked)
and ``independent/`` (server-local) subdirectories.  Each subfolder is a
document containing locale-specific ``.md`` files and a ``_metadata.json``.

The ``contribution_mode`` controls how edits to shared documents are
tracked and exported.  See module docstring for the three modes.
documents_dircontribution_modec                     Xl         US-  U l        US-  U l        US-  U l        X l        0 U l        0 U l        0 U l        0 U l        g )Nr   r   z_attribution.json)	_dir_shared_dir_independent_dir_attribution_pathr   _categories
_documents_scopes_edit_locks)selfr   r   s      <c:\Users\dbart\PlayPalace11\server\core\documents\manager.py__init__DocumentManager.__init__0   sS    !	(83 - =!.1D!D!2!# "!#    returnc                    U R                   R                  SSS9  U R                  R                  SS9  U R                  R                  SS9  U R	                  5         U R                   S-  nUR                  5       (       aB  [        USSS9 n[        R                  " U5      nSSS5        WR                  S	0 5      U l
        O0 U l
        U R                  5         U R                  R                  5         U R                  R                  5         U R                  U R                  [         5        U R                  U R                  ["        5        [%        U R                  5      $ ! , (       d  f       N= f)
zLoad document metadata from disk.

Performs migration from the legacy flat layout if needed, then
loads documents from both ``shared/`` and ``independent/``.

Returns the number of documents loaded.
Tparentsexist_okr#   _metadata.jsonrutf-8encodingN
categories)r   mkdirr   r   _migrate_legacy_layoutexistsopenjsonloadgetr   _save_root_metadatar   clearr   _load_scope_dirSCOPE_SHAREDSCOPE_INDEPENDENTlen)r   root_meta_pathf	root_metas       r   r0   DocumentManager.load?   s"    			t4-##T#2 	##% %55  ""ncG< IIaL	 =(}}\2>D!D$$& 	T--|<T224EF4??## =<s   E##
E1Nc                     [        U R                  R                  5       5       H  nUR                  5       (       d  M  UR                  R                  S5      (       a  M<  UR                  S;   a  MN  U R                  UR                  -  nUR                  5       (       dK  [        R                  " [        U5      [        U5      5        [        R                  SUR                  5        M  [        R                  SUR                  5        M     g)zMove document folders from the legacy flat layout into shared/.

The legacy layout stored documents directly in the documents root
directory.  This migration moves them into the ``shared/``
subdirectory.  The root ``_metadata.json`` is left in place.
_)r   r   z!Migrated document '%s' to shared/z5Skipping migration of '%s': already exists in shared/N)sortedr   iterdiris_dirname
startswithr   r-   shutilmovestrLOGinfowarning)r   entrydests      r   r,   &DocumentManager._migrate_legacy_layout`   s     DII--/0E<<>>zz$$S))zz66##ejj0D;;==CJD	2<ejjIKJJ 1r   	scope_dirscopec                 x   UR                  5       (       d  g[        UR                  5       5       H  nUR                  5       (       d  M  UR                  R                  S5      (       a  M<  US-  nX R                  UR                  '   UR                  5       (       aC  [        USSS9 n[        R                  " U5      U R                  UR                  '   SSS5        M  U R                  U5      U R                  UR                  '   U R                  UR                  5        M     g! , (       d  f       GM	  = f)z-Load documents from a single scope directory.Nr=   r%   r&   r'   r(   )r-   r>   r?   r@   rA   rB   r   r.   r/   r0   r   _generate_default_metadata_save_document_metadata)r   rL   rM   rI   doc_meta_pathr9   s         r   r4   DocumentManager._load_scope_diry   s    !!I--/0E<<>>zz$$S))!$44M',LL$##%%-w?126))A,DOOEJJ/ @? /3.M.Me.T

+,,UZZ8 1 @?s   ).D))
D9	folderc                    [         R                  " [        R                  5      R	                  5       nUR
                  R                  SS5      R                  5       n0 n/ n[        UR                  S5      5       H(  nUR                  nUR                  U5        UUSS.XG'   M*     U(       d  UR                  S5        UUSS.US'   U Vs0 s H  oU_M     n	n/ SU	US.$ s  snf )z<Generate default metadata for a document folder without one.r=    z*.mdTcreatedmodified_contentspublicenr*   source_localetitleslocales)r   nowr   utc	isoformatrA   replacetitler>   globstemappend)
r   rS   r_   rc   r^   locale_codesmd_filelocale_codecoder]   s
             r   rO   *DocumentManager._generate_default_metadata   s    ll8<<(224##C-335 fkk&12G!,,K,%($G  3 %%(GDM +77,$+,7 !	
 	
 8s   C!localec                    / nU R                   R                  5        H\  u  p4UR                  S0 5      nUR                  U5      =(       d    UR                  S5      =(       d    UnUR                  X6S.5        M^     UR	                  S S9  U$ )zgReturn categories sorted by sort order with display names.

Each entry has keys ``slug`` and ``name``.
rA   rZ   )slugrA   c                 (    U S   R                  5       $ )NrA   lower)cs    r   <lambda>0DocumentManager.get_categories.<locals>.<lambda>   s    !F)//"3r   key)r   itemsr1   rf   sort)r   rl   resultrn   rG   namesdisplays          r   get_categoriesDocumentManager.get_categories   sy    
 **002JDHHVR(Eii'B599T?BdGMM49: 3 	34r   c                    [        U R                  5      SS.nU R                  R                  5        HI  nUR                  S/ 5      nU(       d  US==   S-  ss'   U H  nUR                  US5      S-   X'   M     MK     U$ )zReturn document counts per category in a single pass.

Keys are category slugs, plus ``None`` for all documents and
``""`` for uncategorized.
r   )N r*   r      )r7   r   valuesr1   )r   countsmetacatsrn   s        r   get_category_document_counts,DocumentManager.get_category_document_counts   sv     034??/C(KOO**,D88L"-Dr
a
%zz$2Q6 	 - r   category_slugc           
         / nU R                   R                  5        H  u  pEUR                  S/ 5      nUc  OUS:X  a
  U(       a  M*  OX;  a  M2  UR                  S0 5      nUR                  U5      =(       d    UR                  S5      =(       d    UnUR                  SS5      n	UR                  S0 5      R                  U	0 5      n
UR                  UUU
R                  SS5      U
R                  SS5      S	.5        M     U(       a  U R	                  U5      OS
nUS:X  a  UR                  S SS9  U$ US:X  a  UR                  S SS9  U$ UR                  S S9  U$ )a  Return documents in a category.

Args:
    category_slug: Category slug to filter by.  ``None`` returns all
        documents, ``""`` returns uncategorized documents.
    locale: Locale for display title resolution.

Returns a list of dicts with ``folder_name`` and ``title``.
r*   r   r]   rZ   r\   r^   rW   rX   )folder_namerc   rW   modifiedalphabeticaldate_createdc                     U S   $ )NrW    ds    r   rs   ;DocumentManager.get_documents_in_category.<locals>.<lambda>   s    q|r   T)rv   reversedate_modifiedc                     U S   $ )Nr   r   r   s    r   rs   r      s    q}r   c                 (    U S   R                  5       $ )Nrc   rp   r   s    r   rs   r      s    qz'7'7'9r   ru   )r   rw   r1   rf   get_category_sortrx   )r   r   rl   resultsr   r   r   r]   rc   sourceloc_infosort_methods               r   get_documents_in_category)DocumentManager.get_documents_in_category   s]    !%!6!6!8K88L"-D$"$  !,XXh+FJJv&I&**T*:IkEXXot4Fxx	2.2262>HNN#."'||Ir: (-@" E	! "92 @Md,,];R`.(LL3TLB
 	 O+LL4dLC  LL9L:r   r   c                 8    U R                   R                  U5      $ )z6Return the full metadata dict for a document, or None.)r   r1   r   r   s     r   get_document_metadata%DocumentManager.get_document_metadata   s    "";//r   c                 v    U R                   R                  U5      nUc  g[        UR                  S0 5      5      $ )z,Return the number of locales for a document.r   r^   )r   r1   r7   )r   r   r   s      r   get_document_locale_count)DocumentManager.get_document_locale_count  s4    "";/<488Ir*++r   c                 8    U R                   R                  U5      $ )z@Return the scope of a document (shared or independent), or None.)r   r1   r   s     r   get_document_scope"DocumentManager.get_document_scope  s    ||,,r   c                     XR                   ;  a  gU R                  U5      nUc  gX2 S3-  nUR                  5       (       d  gUR                  SS9$ )z4Read a document's ``.md`` file for the given locale.N.mdr'   r(   )r   _document_dirr-   	read_text)r   r   rl   doc_dirmd_paths        r   get_document_content$DocumentManager.get_document_content  sY    oo-$$[1?hcN*~~  ' 22r   contenteditor_usernamec                    XR                   ;  a  gU R                  U5      nUc  gXR S3-  nUR                  5       (       a  U R                  X5        UR	                  USS9  U R                   U   nUR                  S0 5      n[        R                  " [        R                  5      R                  5       n	X(;   a  XU   S'   OU	U	SS.X'   U R                  U5        U R                  XU5        g)	a	  Write document content, back up the previous version, and release lock.

For shared documents, the change is also recorded in the pending
changeset so it can be exported and submitted upstream.

Returns ``True`` on success, ``False`` if the document doesn't exist.
Fr   r'   r(   r^   rX   TrV   )r   r   r-   _backup_version
write_text
setdefaultr   r_   r   r`   ra   rP   release_edit_lock)
r   r   rl   r   r   r   r   r   r^   r_   s
             r   save_document_content%DocumentManager.save_document_content  s     oo-$$[1?hcN* >>  57W5 {+//)R0ll8<<(22436FO/0 %(GO
 	$$[1 	{ODr   r*   rc   c                    XR                   ;   a  gU[        :X  a  U R                  OU R                  nXq-  nUR	                  SSS9  [
        R                  " [        R                  5      R                  5       n	UUX40UU	U	SS.0S.n
XR                   U'   X`R                  U'   U R                  U5        X S3-  nUR                  USS9  g)	a  Create a new document folder with initial content.

Args:
    folder_name: The slug/folder name for the document.
    categories: List of category slugs.
    locale: The initial locale code.
    title: The display title for the initial locale.
    content: The markdown content for the initial locale.
    scope: Either ``SCOPE_SHARED`` or ``SCOPE_INDEPENDENT``.

Returns ``False`` if a document with that folder name already exists.
FTr!   rV   r[   r   r'   r(   )r   r5   r   r   r+   r   r_   r   r`   ra   r   rP   r   )r   r   r*   rl   rc   r   rM   rL   r   r_   r   r   s               r   create_documentDocumentManager.create_documentK  s    * //)(-(=D$$4CXCX	)dT2ll8<<(224$#o"),"	
 (,$$)[!$$[1hcN*7W5r   c                     U R                   R                  U5      nUc  gUR                  S0 5      nX5U'   U R                  U5        g)a	  Update the title for a locale in document metadata.

Titles are stored separately from locale entries, so setting a title
for a locale that has no translation yet does not create a locale entry.

Returns ``True`` on success, ``False`` if the document is not found.
Fr]   T)r   r1   r   rP   )r   r   rl   rc   r   r]   s         r   set_document_title"DocumentManager.set_document_title}  sH     "";/<2.v$$[1r   rY   c                     U R                   R                  U5      nUc  gUR                  S0 5      nX%;  a  gX5U   S'   U R                  U5        g)zUpdate the public flag for a locale in document metadata.

Returns ``True`` on success, ``False`` if document or locale not found.
Fr^   rY   Tr   r1   rP   )r   r   rl   rY   r   r^   s         r   set_document_visibility'DocumentManager.set_document_visibility  sV    
 "";/<((9b) $*!$$[1r   c                 l    U R                   R                  U5      nUc  gX#S'   U R                  U5        g)ziReplace the category list for a document.

Returns ``True`` on success, ``False`` if document not found.
Fr*   Tr   )r   r   r*   r   s       r   set_document_categories'DocumentManager.set_document_categories  s9    
 "";/<'\$$[1r   c                    U R                   R                  U5      nUc  gUR                  S0 5      nX&;   a  gU R                  U5      nUc  g[        R
                  " [        R                  5      R                  5       nUUSS.Xb'   UR                  S0 5      n	X9U'   Xr S3-  n
U
R                  USS9  U R                  U5        g)	zCreate a new locale entry (private by default) and write the .md file.

Returns ``False`` if the document doesn't exist or the locale already exists.
Fr^   rV   r]   r   r'   r(   T)r   r1   r   r   r   r_   r   r`   ra   r   rP   )r   r   rl   rc   r   r   r^   r   r_   r]   r   s              r   add_document_translation(DocumentManager.add_document_translation  s     "";/<//)R0$$[1?ll8<<(224!$

 2.vhcN*7W5$$[1r   T)remove_titler   c                \   U R                   R                  U5      nUc  gUR                  S5      U:X  a  gUR                  S0 5      nX%;  a  gU R                  U5      nUc  gXR	 U(       a"  UR                  S0 5      R                  US5        Xb S3-  nUR	                  5       (       a  UR                  5         US-  nUR	                  5       (       a+  UR                  U S35       H  n	U	R                  5         M     U R                  U5        U R                  R                  X4S5        g	)
a  Delete a locale entry, its .md file, and history backups.

When *remove_title* is ``False`` the title for the locale is kept
in metadata so it can be reused if the translation is re-added later.

Returns ``False`` if the locale is the source locale or doesn't exist.
NFr\   r^   r]   r   _history_*.mdT)	r   r1   r   popr-   unlinkrd   rP   r   )
r   r   rl   r   r   r^   r   r   history_dirbackups
             r   remove_document_translation+DocumentManager.remove_document_translation  s    "";/<88O$.((9b) $$[1?OHHXr"&&vt4hcN*>>NN
*%**fXU+;< =$$[1k2D9r   c                 X   XR                   ;  a  gU R                  U5      nUb+  UR                  5       (       a  [        R                  " U5        U R                   U	 U R
                  U	 U R                   Vs/ s H  o3S   U:X  d  M  UPM     nnU H  nU R                  U	 M     gs  snf )zfRemove a document folder from disk and from memory.

Returns ``False`` if the document doesn't exist.
Fr   T)r   r   r-   rC   rmtreer   r   )r   r   r   rv   stales        r   delete_documentDocumentManager.delete_document  s    
 oo-$$[17>>#3#3MM'"OOK(LL% $ 0 0J 0Fk4I 0JC  %  Ks   8B'B'rn   rA   c                 j    XR                   ;   a  gSX20S.U R                   U'   U R                  5         g)zFCreate a new category.

Returns ``False`` if the slug already exists.
Fr   )rx   rA   T)r   r2   )r   rn   rA   rl   s       r   create_categoryDocumentManager.create_category  s>    
 ###"N"
 	  "r   c                    XR                   ;  a  gU R                   U	 U R                  5         U R                  R                  5        H@  u  p#UR	                  S/ 5      nX;   d  M  UR                  U5        U R                  U5        MB     g)zfDelete a category and remove it from all documents.

Returns ``False`` if the category doesn't exist.
Fr*   T)r   r2   r   rw   r1   removerP   )r   rn   r   r   r   s        r   delete_categoryDocumentManager.delete_category  s{    
 '''T"  "!%!6!6!8K88L"-D|D!,,[9	 "9
 r   c                     U R                   R                  U5      nUc  gX$R                  S0 5      U'   U R                  5         g)zoUpdate the display name for a category in a specific locale.

Returns ``False`` if the category doesn't exist.
FrA   T)r   r1   r   r2   )r   rn   rA   rl   cats        r   rename_categoryDocumentManager.rename_category   sD    
 ""4(;-1vr"6*  "r   r   c                 j    U R                   R                  U5      nUc  gX#S'   U R                  5         g)zYUpdate the sort method for a category.

Returns ``False`` if the category doesn't exist.
Frx   T)r   r1   r2   )r   rn   r   r   s       r   set_category_sort!DocumentManager.set_category_sort,  s8    
 ""4(;!F  "r   c                 d    U R                   R                  U5      nUc  gUR                  SS5      $ )zCReturn the sort method for a category (default ``"alphabetical"``).r   rx   )r   r1   )r   rn   r   s      r   r   !DocumentManager.get_category_sort8  s1    ""4(;!wwv~..r   c                 H   [         R                  " SU 5      nUR                  SS5      R                  S5      nUR	                  5       n[
        R                  " SSU5      n[
        R                  " SSU5      n[
        R                  " SSU5      nUR                  S5      nU$ )	zConvert a document title to a folder-name slug.

Lowercases, replaces whitespace/hyphens with underscores, strips
non-ASCII and special characters, and collapses runs of underscores.
NFKDasciiignorez[\s\-]+r=   z[^\w]r   z_+)unicodedata	normalizeencodedecoderq   resubstrip)rc   rn   s     r   slugifyDocumentManager.slugify?  s     $$VU3{{7H-44W=zz|vvj#t,vvhD)vveS$'zz#r   usernamec                     U R                  5         X4nU R                  R                  U5      nU(       a  US   U:w  a  US   $ U[        R                  " 5       S.U R                  U'   g)zcAttempt to acquire an edit lock.

Returns ``None`` on success, or the locking username on failure.
user)r   	timestampN)cleanup_stale_locksr   r1   timer   r   rl   r   rv   existings         r   acquire_edit_lock!DocumentManager.acquire_edit_lockS  sf    
 	  "###'',(H4F##)1		 Lr   c                 ~    X4nU R                   R                  U5      nU(       a  US   U:X  a  U R                   U	 ggg)z+Release an edit lock if held by *username*.r   N)r   r1   r   s         r   r   !DocumentManager.release_edit_lock`  sD    ###'',(H4  % 58r   c                 v    U R                  5         U R                  R                  X45      nU(       a  US   $ S$ )z2Return the username holding the lock, or ``None``.r   N)r   r   r1   )r   r   rl   locks       r   get_edit_lock_holder$DocumentManager.get_edit_lock_holderg  s8      "##[$9:#tF|--r   c                     U R                  5         U R                  R                  5        VVVs0 s H  u  u  p#nX!:X  d  M  X4S   _M     snnn$ s  snnnf )zEReturn ``{locale: username}`` for every active lock on *folder_name*.r   )r   r   rw   )r   r   docrl   r  s        r   get_document_lock_holders)DocumentManager.get_document_lock_holdersm  sZ      " (,'7'7'='='?
'?#t! !FL '?
 	
 
s   A	Atimeout_secondsc                     [         R                   " 5       nU R                  R                  5        VVs/ s H  u  p4X$S   -
  U:  d  M  UPM     nnnU H  nU R                  U	 M     gs  snnf )z*Remove locks older than *timeout_seconds*.r   N)r   r   rw   )r   r  r_   rv   r  r   s         r   r   #DocumentManager.cleanup_stale_locksv  so    iik "--335
5	+&&8 5 	 

 C  % 
s   A(A(change_typemessagec           
          U R                  5       nUR                  UUUUU[        R                  " [        R
                  5      R                  5       S.5        U R                  U5        g)zAppend an entry to the attribution log.

Only used in manual mode.  The log records which in-app user
made each edit so the export ZIP can include proper credit.
)r   rl   editorr  r  r   N)_load_attribution_logrf   r   r_   r   r`   ra   _save_attribution_log)r   r   rl   r   r  r  entriess          r   _log_attribution DocumentManager._log_attribution  s[     ,,.* )*"%\\(,,7AAC		
 	""7+r   c                     U R                   R                  5       (       aK  [        U R                   SSS9 n[        R                  " U5      nSSS5        [        W[        5      (       a  U$ / $ ! , (       d  f       N'= f)z2Load the attribution log, or return an empty list.r&   r'   r(   N)r   r-   r.   r/   r0   
isinstancelist)r   r9   datas      r   r  %DocumentManager._load_attribution_log  s_    !!((**d,,cGDyy| E$%%		 EDs   A--
A;r  c                     [        U R                  SSS9 n[        R                  " XSSS9  SSS5        g! , (       d  f       g= f)z"Write the attribution log to disk.wr'   r(      Findentensure_asciiN)r.   r   r/   dump)r   r  r9   s      r   r  %DocumentManager._save_attribution_log  s3    $((#@AIIg? A@@s	   6
Ac                 "    U R                  5       $ )z+Return the current attribution log entries.)r  r   s    r   get_attribution_log#DocumentManager.get_attribution_log  s    ))++r   c                    U R                   [        :X  a  gU R                  5       nUc  gU R                  U5      nUc  SSU S34$  [	        UR                  5       R                  UR                  5       5      5      n[        R                  " SSS	U/[	        U5      S
S
SS9nUR                  S:w  a=  UR                  R                  5       =(       d    Sn	[        R                  SU	5        SU	4$ UR                  5       (       d  SU SU 3nU S3n
[        R                  " SSSUSU
/[	        U5      S
S
SS9nUR                  S:w  ay  UR                  R                  5       =(       d    Sn	SU	R                  5       ;   d'  SUR                   =(       d    SR                  5       ;   a  g[        R                  SU	5        SU	4$ g! [         a     gf = f)zStage and commit changes for a shared document.

Runs ``git add`` on the document's files, then ``git commit``
with the given message and ``--author`` set to the in-app user.

Returns ``(success, error_message)``.
)Fz'Auto-commit is disabled in manual mode.FzNot inside a git repository.Fz
Document 'z' not found.)Fz1Document directory is outside the git repository.gitadd--T
   cwdcapture_outputtexttimeoutr   Unknown git error.zgit add failed: %szUpdate /z <noreply@playpalace>commitz-mz--authorznothing to commitr   )Tr   zgit commit failed: %s)r   MODE_MANUAL_find_git_rootr   rE   resolverelative_to
ValueError
subprocessrun
returncodestderrr   rF   errorrq   stdout)r   r   rl   r   r  	repo_rootr   rel_dir
add_resultr<  authorcommit_results               r   commit_changesDocumentManager.commit_changes  s    !![0C'')	8$$[1?J{m<@@@	N'//+77	8I8I8KLMG
  ^^E4)I

   A%%%++-E1EEII*E2%< }}}AfX6G#$$9:"xgF
 I

 ##q(!((..0H4HE"ekkm37J$$*eg8  II-u5%<W  	NM	Ns   6G 
GGc                     U R                  5       nUc  / $ U R                  [        :X  a  U R                  U5      $ U R	                  U5      $ )zReturn pending changes for shared documents.

In manual mode, returns file paths that differ from HEAD
(uncommitted working-tree changes).  In auto modes, returns
commit subject lines ahead of ``origin/main``.
)r4  r   r3  _get_uncommitted_changes_get_commits_ahead)r   r>  s     r   get_pending_changes#DocumentManager.get_pending_changes  sM     '')	I!![000;;&&y11r   r>  c                 2    [        U R                  R                  5       R                  UR                  5       5      5      n/ n [
        R                  " SSSSSU/[        U5      SSSS9nUR                  S	:X  a>  UR                  S
 UR                  R                  5       R                  5        5       5         [
        R                  " SSSSSU/[        U5      SSSS9nUR                  S	:X  aP  UR                  R                  5       R                  5        H$  nU(       d  M  XS;  d  M  UR                  U5        M&     U$ ! [         a    / s $ f = f! [        [
        R                  4 a    / s $ f = f! [        [
        R                  4 a     U$ f = f)z9Return file paths with uncommitted changes under shared/.r'  diff--name-onlyHEADr)  Tr*  r+  r   c              3   6   #    U  H  o(       d  M  Uv   M     g 7fNr   ).0lines     r   	<genexpr>;DocumentManager._get_uncommitted_changes.<locals>.<genexpr>&  s      %GT4DD%Gs   
	ls-files--others--exclude-standard)rE   r   r5  r6  r7  r8  r9  r:  extendr=  r   
splitlinesFileNotFoundErrorTimeoutExpiredrf   )r   r>  
rel_sharedchangedry   rQ  s         r   rF  (DocumentManager._get_uncommitted_changes  s   	  ((*66y7H7H7JKJ  	^^vtZH	N#F   A% %+]]%8%8%:%E%E%G 	^^: 4*
 	N#
F   A%"MM//1<<>Dt 3t, ? S  	I	$ ":#<#<= 	I	( ":#<#<= 		sD   A E A5E ;A'E8 &E8 -E8 EEE54E58FFc                 >    [         R                  " / SQ[        U5      SSSS9nUR                  S:X  aA  UR                  R                  5       R                  5        Vs/ s H  o3(       d  M  UPM     sn$  / $ s  snf ! [        [         R                  4 a     / $ f = f)z1Return commit subject lines ahead of origin/main.)r'  logz	--onelinezorigin/main..HEADTr*  r+  r   )	r8  r9  rE   r:  r=  r   rX  rY  rZ  )r   r>  ry   rQ  s       r   rG  "DocumentManager._get_commits_aheadB  s    	^^ 	N#	F   A%%+]]%8%8%:%E%E%G%GT4D%G  & 	 ":#<#<= 			s*   AA> 
A9-A93A> 9A> >BBc                 4    [        U R                  5       5      $ )z6Return the number of pending changes or commits ahead.)r7   rH  r"  s    r   get_pending_change_count(DocumentManager.get_pending_change_countW  s    4++-..r   c                    U R                  5       nUc  / $  [        U R                  R                  5       R	                  UR                  5       5      5      R                  SS5      n/ n [        R                  " SSSSSU/[        U5      S	S	S
S9nUR                  S:X  a@  UR                  R                  5       R                  5        Vs/ s H  oU(       d  M  UPM     nn/ n [        R                  " SSSSSU/[        U5      S	S	S
S9nUR                  S:X  a@  UR                  R                  5       R                  5        Vs/ s H  oU(       d  M  UPM     nnU Vs1 s H  owR                  SS5      iM     nn0 n	X6-    H  n
U
R                  SS5      nUR                  US-   5      (       d  M0  U[!        U5      S-   S nUR#                  S5      nU(       d  M[  US   n[!        U5      S:  a  US   OSnU	R%                  USSSS.5      nX;   a  S	US'   UR'                  S5      (       a  S	US'   M  US:X  d  M  S	US'   M     / nU	R)                  5        H  u  nnU R                  U-  nUR+                  5       (       d  SnOGUS   (       a  US   (       d  US   (       d  SnO&US   (       a  US   (       a  SnOUS   (       a  SnOSnUR-                  UUS.5        M     U$ ! [         a    / s $ f = fs  snf ! [        [        R                  4 a    / s $ f = fs  snf ! [        [        R                  4 a     GNf = fs  snf )zReturn shared documents with uncommitted changes and descriptions.

Each entry is a dict with ``folder_name`` and ``change_tag`` keys.
``change_tag`` is one of: ``"absent"``, ``"present"``, ``"content"``,
``"metadata"``, or ``"content_and_metadata"``.
N\r1  r'  rK  rL  rM  r)  Tr*  r+  r   rT  rU  rV  r   r   F)r   metadatanewrg  r   r   r%   rf  absentpresentcontent_and_metadata)r   
change_tag)r4  rE   r   r5  r6  rb   r7  r8  r9  r:  r=  r   rX  rY  rZ  rB   r7   splitr   endswithrw   r-   rf   )r   r>  r[  modified_pathsry   luntracked_pathspuntracked_setfolder_infopath
normalizedrestpartsr   filenamerG   r   r   tags                       r    get_uncommitted_shared_documents0DocumentManager.get_uncommitted_shared_documents[  s_    '')	I	  ((*66y7H7H7JKgdC   %'	^^vtZH	N#F   A%%}}224??A"A!QAA  " &(	^^: 4*
 	N#
F   A%%}}224??A#A!QAA   # 8GG!4-G 35"4DdC0J((c)9::c*o123DJJsOE(K#&u:>uQxrH))E%PD *"U  ''"&Y--#'Z ' 5* !!,!2!2!4K&&4G>>##eT)_T*=MiT*%5,j! NN;cJK "5 e  	I	" ":#<#<= 	I	$# ":#<#<= 		 Hsn   AK& *A!K= 
K8K8K= $A!L$ 
LLL$ M&K54K58K= =LLL$ $MMc                    U R                  5       nUc  gU R                  U-  nUR                  5       (       d    [        UR	                  5       R                  UR	                  5       5      5      n[        R                  " SSSSU/[        U5      SSSS9nUR                  S	:w  a/  [        R                  S
XR                  R                  5       5        gg! [         a     gf = f)zDiscard uncommitted changes for a specific shared document.

Restores the document's directory to match HEAD.
Returns ``True`` on success.
Fr'  checkoutrM  r)  Tr*  r+  r   z$Failed to discard changes for %s: %s)r4  r   r-   rE   r5  r6  r7  r8  r9  r:  rF   r<  r;  r   )r   r   r>  r   rel_pathry   s         r   discard_document_changes(DocumentManager.discard_document_changes  s     '')	""[0~~	!--i.?.?.ABH Jh7I
 !II<!==#6#6#8:  		s   6C 
C$#C$output_pathc           
      8   U R                  U R                  5       =(       d
    [        5       5      nU(       d  gU R                  5       nUc  gUR                  R	                  SSS9  Sn[
        R                  " US[
        R                  5       nU H  nX6-  nUR                  5       (       d  M  UR                  5       (       d  M5   [        UR                  5       R                  U R                  R                  5       5      5      nUR                  Xx5        US-  nM     U R!                  5       n	UR#                  S[$        R&                  " SU	0S	S
S95        SSS5        US:X  a  UR)                  SS9  U$ ! [         a    Un Nf = f! , (       d  f       N7= f)a"  Package uncommitted shared document changes into a ZIP.

Only used in manual mode.  The ZIP mirrors the ``shared/``
directory structure with only the changed files, plus an
``attribution.json`` built from the attribution log.

Returns the number of files included, or 0 if nothing changed.
r   NTr!   r  r   zattribution.jsonchangesr  Fr  )
missing_ok)rF  r4  r   parentr+   zipfileZipFileZIP_DEFLATEDr-   is_filerE   r5  r6  r   r7  writer  writestrr/   dumpsr   )
r   r  changed_pathsr>  includedzfr~  abs_patharchive_pathattributions
             r   export_pending_changes&DocumentManager.export_pending_changes  st    55!+TV
 '')	   =__[#w/C/CD)$/??$$)9)9););0'*$,,.:: $		 1 1 3( HHX4MH * 446KKK"

,!&! E2 q=$/% & 0'/0 EDs8   F0FA E9AF9FFFF
Fc                 X   U R                  5       nUc  gU R                  U5      nU(       d  g[        R                  " [        R
                  5      R                  S5      nSU 3n [        R                  " SSSU/[        U5      SSS	S
9nUR                  S:w  a%  SUR                  R                  5       =(       d    S4$ [        R                  " SSSSU/[        U5      SSSS
9nUR                  S:w  aG  [        R                  " / SQ[        U5      SS	S9  SUR                  R                  5       =(       d    S4$ S[        U5       S3nSnUS-  nU H  nUSU S3-  nM     [        R                  " SSSSUSUS S!/	[        U5      SSS"S
9n[        R                  " / SQ[        U5      SS	S9  UR                  S:w  a'  UR                  R                  5       =(       d    S#n	SU	4$ UR                  R                  5       n
SU
4$ ! [         a     g$[        R                    a     g%f = f)&zCreate a pull request from local commits via ``gh`` CLI.

Only used in auto_pr mode.  Creates a branch from the current
commits ahead of ``origin/main``, pushes it, and opens a PR.

Returns ``(success, pr_url_or_error)``.
r&  )Fz(No commits to include in a pull request.z%Y%m%d-%H%M%Sz
documents/r'  r}  z-bTr*  r+  r   FzFailed to create branch.pushz-uorigin<   )r'  r}  main)r,  r-  r/  zFailed to push branch.zDocument updates (z	 commits)z9Automated document contribution from PlayPalace server.

z## Commits
z- 
ghprcreatez--titlez--bodyz--baser     zFailed to create pull request.)Fz.Git or gh CLI is not installed or not in PATH.)FzOperation timed out.)r4  rG  r   r_   r   r`   strftimer8  r9  rE   r:  r;  r   r7   r=  rY  rZ  )r   r>  commitsr   branchry   rc   bodyrQ  r<  pr_urls              r   create_pull_request#DocumentManager.create_pull_request  s>    '')	8)))4DLL.77H	i[)D	1^^
D&1	N#F   A%fmm113Q7QQQ  ^^h7	N#F   A%/I#'	 fmm113O7OOO )Wi@EPDN"D"TF"%    ^^$udf	 	N#F NN+	N#	   A%++-Q1Qe|#]]((*F<  	KJ(( 	10	1s-   'AH A<H >B*H )H 
H)H)(H)c                     U R                   [        :X  a;  U R                  R                  5       (       a  U R                  R	                  5         ggg)zjClear the attribution log (manual mode only).

In auto modes this is a no-op since git log is the record.
N)r   r3  r   r-   r   r"  s    r   clear_pending_changes%DocumentManager.clear_pending_changesr  sA    
 !![0T5K5K5R5R5T5T""))+ 6U0r   c           	         U R                  5       nUc  g U R                  [        :w  au  [        R                  " / SQ[        U5      SSSS9nUR                  S:w  a@  UR                  R                  5       =(       d    Sn[        R                  SU5        S	S
U 34$ GO)U R                  R                  5       R                  UR                  5       5      n[        R                  " / SQ[        U5      SSSS9nUR                  S:w  a@  UR                  R                  5       =(       d    Sn[        R                  SU5        S	SU 34$ [        R                  " SSSS[        U5      /[        U5      SSSS9nUR                  S:w  a@  UR                  R                  5       =(       d    Sn[        R                  SU5        S	S
U 34$ [        U R                  5      nU R!                  5         [        U R                  5      nSU S3n	X:w  a	  U	SU S3-  n	[        R#                  U	5        SU	4$ ! [$         a     g[        R&                   a     gf = f)a  Sync shared documents from the remote repository.

In auto modes, uses ``git pull --rebase`` to preserve local
commits on top of upstream changes.  In manual mode, fetches
and checks out ``origin/main`` (overwriting local edits).

Returns a ``(success, message)`` tuple.
r&  )r'  pullz--rebasez--autostashr  r  Tr  r+  r   r0  zDocument sync pull failed: %sFzSync failed: )r'  fetchr  zDocument sync fetch failed: %szFetch failed: r'  r}  zorigin/mainr)  r  z!Document sync checkout failed: %szSynced shared documents. z documents loaded.z (was ))Fz$Git is not installed or not in PATH.)FzSync timed out.)r4  r   r3  r8  r9  rE   r:  r;  r   rF   r<  r   r5  r6  r7   r   r0   rG   rY  rZ  )
r   r>  ry   r<  r~  r  r}  	old_count	new_countmsgs
             r   sync_shared_documents%DocumentManager.sync_shared_documents~  s9    '')	8;	,%%4
 $PI#' $$)"MM//1I5IEII=uE M%"999 *  ++335AA%%' #.I#' ##q(!LL..0H4HEII>F N5'":::%>>JtS]KI#' &&!+$OO113K7KEIIA5I M%"999 DOO,IIIKDOO,I-i[8JKC%	{!,,HHSM9  	A@(( 	,+	,s-   BH/ B+H/ 	A>H/ A&H/ /
I;IIc                     [         R                  " / SQ[        U R                  5      SSSS9nUR                  S:X  a#  [        UR                  R                  5       5      $  g! [        [         R                  4 a     gf = f)z7Find the git repository root, or None if not in a repo.)r'  z	rev-parsez--show-toplevelTr   r+  r   N)
r8  r9  rE   r   r:  r   r=  r   rY  rZ  )r   ry   s     r   r4  DocumentManager._find_git_root  s    	^^7		N#F   A%FMM//122 &  ":#<#<= 		s   AA$ $B Bc                    XR                   ;  a  gU R                  R                  U5      [        :w  a  gU R                  U-  nU R
                  U-  nUR                  5       (       a  [        R                  SU5        g[        R                  " [        U5      [        U5      5        [        U R                  U'   [        R                  SU5        g)zyMove a document from independent to shared scope.

Returns ``False`` if the document doesn't exist or is already shared.
Fz.Cannot promote '%s': already exists in shared/z!Promoted document '%s' to shared.T)r   r   r1   r6   r   r   r-   rF   rH   rC   rD   rE   r5   rG   )r   r   srcrJ   s       r   promote_to_shared!DocumentManager.promote_to_shared  s    
 oo-<<K(,==##k1+-;;==KK@ CHc$i($0[!4kBr   c                 h    [         R                  " U R                  S5      5      R                  5       $ )z+Compute a SHA-256 hash of document content.r'   )hashlibsha256r   	hexdigest)r   s    r   content_hashDocumentManager.content_hash  s%     ~~gnnW56@@BBr   shared_slugc                    U R                   R                  U5      nUc  gU R                  R                  U5      [        :w  a  gU R	                  X#5      nUc  gUUU R                  U5      S.US'   U R                  U5        g)a7  Set the ``based_on`` field for an independent document.

Records which shared document and content hash this independent
document was derived from, so the system can detect when the
upstream version changes.

Returns ``False`` if the document or shared source doesn't exist,
or the document is not independent.
F)rn   rl   r  based_onT)r   r1   r   r6   r   r  rP   )r   r   r  rl   r   shared_contents         r   set_based_onDocumentManager.set_based_on  s     "";/<<<K(,==22;G! --n=
Z
 	$$[1r   c                 D   U R                   R                  U5      nUc  gUR                  S5      nUc  gUR                  S5      nUR                  SS5      nUR                  S5      nU(       a  U(       d  gU R                  XE5      nUc  gU R                  U5      nX:g  $ )a  Check if an independent document's upstream source has changed.

Returns ``True`` if the shared source content has changed since
the ``based_on`` hash was recorded, ``False`` if unchanged,
or ``None`` if the document has no ``based_on`` field or the
shared source no longer exists.
Nr  rn   rl   rZ   r  )r   r1   r   r  )	r   r   r   r  r  rl   stored_hashr  current_hashs	            r   check_based_on_stale$DocumentManager.check_based_on_stale  s     "";/<88J'll6*h-ll>2+22;G!((8**r   c                     U R                   R                  U5      nU[        :X  a  U R                  U-  $ U[        :X  a  U R
                  U-  $ g)z<Return the directory path for a document based on its scope.N)r   r1   r5   r   r6   r   )r   r   rM   s      r   r   DocumentManager._document_dir:  sL      -L ##k11''((;66r   c                     U R                   S-  n[        USSS9 n[        R                  " SU R                  0USSS9  S	S	S	5        g	! , (       d  f       g	= f)
z0Write categories to the root ``_metadata.json``.r%   r  r'   r(   r*   r  Fr  N)r   r.   r/   r  r   )r   rt  r9   s      r   r2   #DocumentManager._save_root_metadataG  sI    yy++$g.!II|T%5%56!RWX /..s   $A
Ac                     U R                   R                  U5      nUc  gU R                  U5      nUc  gUS-  n[        USSS9 n[        R
                  " X%SSS9  SSS5        g! , (       d  f       g= f)	z&Write a document's ``_metadata.json``.Nr%   r  r'   r(   r  Fr  )r   r1   r   r.   r/   r  )r   r   r   r   rt  r9   s         r   rP   'DocumentManager._save_document_metadataM  sk    "";/<$$[1?))$g.!IIdae< /..s   A%%
A3c                    U R                  U5      nUc  gX2 S3-  nUR                  5       (       d  gUS-  nUR                  SS9  [        R                  " [
        R                  5      R                  S5      nU SU S3n[        R                  " XEU-  5        [        UR                  U S35      S	 S
9n[        U5      [        :  a7  UR                  S5      n	U	R                  5         [        U5      [        :  a  M6  gg)zBCopy the current ``.md`` to ``_history/`` and enforce version cap.Nr   r   Tr$   z%Y%m%dT%H%M%SZr=   r   c                     U R                   $ rO  )rA   )rq  s    r   rs   1DocumentManager._backup_version.<locals>.<lambda>l  s    !&&r   ru   r   )r   r-   r+   r   r_   r   r`   r  rC   copy2r>   rd   r7   _MAX_HISTORY_PER_LOCALEr   r   )
r   r   rl   r   r   r   r   backup_namebackupsoldests
             r   r   DocumentManager._backup_versionY  s    $$[1?hcN*~~
*4(LL.778HI	)C0WK78 xu-. 
 'l44[[^FMMO 'l44r   )	r   r   r   r   r   r   r   r   r   )r   N)i  )r   )G__name__
__module____qualname____firstlineno____doc__MODE_AUTO_COMMITr   rE   r   intr0   r,   r4   dictrO   r  r|   r   r   r   r   r   r   boolr   r6   r   r   r   r   r   r   r   r   r   r   r   r   staticmethodr   r   r   r  r  r   r  r  r  r#  tuplerC  rH  rF  rG  rb  rz  r  r  r  r  r  r4  r  r  r  r  r   r2   rP   r   __static_attributes__r   r   r   r   r   %   s    FV 	$d 	$s 	$$c $B29 9c 9d 9(!
 !
$ !
NS T$Z d3:s?.C +sTz +3 +SWX\S] +Z0 0 0,S ,S ,-c -cDj -
3 
3S 
3S4Z 
3 -- - 	-
 - 
-l '00 I0 	0
 0 0 0 
0dc 3 s t  3  T VZ 
3 
DI 
RV 
(+47BE	F "'' '
 ' 
'R3 4 $C s C D C D "
C 
s 
C 
D 

c 
 
 
/c /c / s s  &S #  QTW[Q[ &S &# & &QU &. .S .S4Z .
S 
T#s(^ 
	&3 	&$ 	&* ,, , 	,
 , , 
,4tDz @T$Z @D @
,T$Z ,GG G 	G
 G 
tSy	GZ2T#Y 2/$ /49 /bD T#Y */# /a$t* aF!C !D !N2$ 23 2hW1U49%5 W1r,H,uT3Y'7 H,Tt (S T 8 Cc Cc C C  	
 
@+ +t +:  Y
=3 
=4 
=3   r   r   )r  r  r/   loggingr   rC   r8  r   r   r  r   r   pathlibr   	getLoggerrF   r  r5   r6   r3  r  MODE_AUTO_PRr   r   r   r   <module>r     sk   
    	      ' ./ !   K Kr   