[
  {
    "path": ".gitignore",
    "content": "*.zip\n*.7z\n*.tar\n*.gz\n*.tar.gz\n.aider*\n\n# Builds and Downloads\noutput/\ninstagram*/\n\n# Virtual Environments\nvenv/\n\n# Python bytecode\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\n*.cpython-*\n\n# System Files\n.DS_Store\n\n# Comments test secrets\ncomments-test/.env\ncomments-test/instagram_cookies.json\ncomments-test/runs/\ncomments-test/\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.10-slim\n\n# Install system dependencies (including support for image processing and libmagic)\nRUN apt-get update && apt-get install -y \\\n    libgl1 \\\n    libglib2.0-0 \\\n    libjpeg-dev \\\n    zlib1g-dev \\\n    libmagic-dev \\\n    file \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Set up working directory\nWORKDIR /app\n\n# Copy requirements file\nCOPY requirements.txt .\n\n# Install dependencies from requirements.txt\nRUN pip install --no-cache-dir -r requirements.txt\n\n# Copy application code\nCOPY . .\n\n# Create directories for input/output\nRUN mkdir -p /input /output\n\n# Set the entrypoint\nENTRYPOINT [\"python\", \"-m\", \"memento_mori.cli\"]\n\n# Default command if none provided\nCMD [\"--help\"]"
  },
  {
    "path": "LICENSE",
    "content": "                  GNU LESSER GENERAL PUBLIC LICENSE\n                       Version 2.1, February 1999\n\n Copyright (C) 1991, 1999 Free Software Foundation, Inc.\n 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n[This is the first released version of the Lesser GPL.  It also counts\n as the successor of the GNU Library Public License, version 2, hence\n the version number 2.1.]\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicenses are intended to guarantee your freedom to share and change\nfree software--to make sure the software is free for all its users.\n\n  This license, the Lesser General Public License, applies to some\nspecially designated software packages--typically libraries--of the\nFree Software Foundation and other authors who decide to use it.  You\ncan use it too, but we suggest you first think carefully about whether\nthis license or the ordinary General Public License is the better\nstrategy to use in any particular case, based on the explanations below.\n\n  When we speak of free software, we are referring to freedom of use,\nnot price.  Our General Public Licenses are designed to make sure that\nyou have the freedom to distribute copies of free software (and charge\nfor this service if you wish); that you receive source code or can get\nit if you want it; that you can change the software and use pieces of\nit in new free programs; and that you are informed that you can do\nthese things.\n\n  To protect your rights, we need to make restrictions that forbid\ndistributors to deny you these rights or to ask you to surrender these\nrights.  These restrictions translate to certain responsibilities for\nyou if you distribute copies of the library or if you modify it.\n\n  For example, if you distribute copies of the library, whether gratis\nor for a fee, you must give the recipients all the rights that we gave\nyou.  You must make sure that they, too, receive or can get the source\ncode.  If you link other code with the library, you must provide\ncomplete object files to the recipients, so that they can relink them\nwith the library after making changes to the library and recompiling\nit.  And you must show them these terms so they know their rights.\n\n  We protect your rights with a two-step method: (1) we copyright the\nlibrary, and (2) we offer you this license, which gives you legal\npermission to copy, distribute and/or modify the library.\n\n  To protect each distributor, we want to make it very clear that\nthere is no warranty for the free library.  Also, if the library is\nmodified by someone else and passed on, the recipients should know\nthat what they have is not the original version, so that the original\nauthor's reputation will not be affected by problems that might be\nintroduced by others.\n\n  Finally, software patents pose a constant threat to the existence of\nany free program.  We wish to make sure that a company cannot\neffectively restrict the users of a free program by obtaining a\nrestrictive license from a patent holder.  Therefore, we insist that\nany patent license obtained for a version of the library must be\nconsistent with the full freedom of use specified in this license.\n\n  Most GNU software, including some libraries, is covered by the\nordinary GNU General Public License.  This license, the GNU Lesser\nGeneral Public License, applies to certain designated libraries, and\nis quite different from the ordinary General Public License.  We use\nthis license for certain libraries in order to permit linking those\nlibraries into non-free programs.\n\n  When a program is linked with a library, whether statically or using\na shared library, the combination of the two is legally speaking a\ncombined work, a derivative of the original library.  The ordinary\nGeneral Public License therefore permits such linking only if the\nentire combination fits its criteria of freedom.  The Lesser General\nPublic License permits more lax criteria for linking other code with\nthe library.\n\n  We call this license the \"Lesser\" General Public License because it\ndoes Less to protect the user's freedom than the ordinary General\nPublic License.  It also provides other free software developers Less\nof an advantage over competing non-free programs.  These disadvantages\nare the reason we use the ordinary General Public License for many\nlibraries.  However, the Lesser license provides advantages in certain\nspecial circumstances.\n\n  For example, on rare occasions, there may be a special need to\nencourage the widest possible use of a certain library, so that it becomes\na de-facto standard.  To achieve this, non-free programs must be\nallowed to use the library.  A more frequent case is that a free\nlibrary does the same job as widely used non-free libraries.  In this\ncase, there is little to gain by limiting the free library to free\nsoftware only, so we use the Lesser General Public License.\n\n  In other cases, permission to use a particular library in non-free\nprograms enables a greater number of people to use a large body of\nfree software.  For example, permission to use the GNU C Library in\nnon-free programs enables many more people to use the whole GNU\noperating system, as well as its variant, the GNU/Linux operating\nsystem.\n\n  Although the Lesser General Public License is Less protective of the\nusers' freedom, it does ensure that the user of a program that is\nlinked with the Library has the freedom and the wherewithal to run\nthat program using a modified version of the Library.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.  Pay close attention to the difference between a\n\"work based on the library\" and a \"work that uses the library\".  The\nformer contains code derived from the library, whereas the latter must\nbe combined with the library in order to run.\n\n                  GNU LESSER GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License Agreement applies to any software library or other\nprogram which contains a notice placed by the copyright holder or\nother authorized party saying it may be distributed under the terms of\nthis Lesser General Public License (also called \"this License\").\nEach licensee is addressed as \"you\".\n\n  A \"library\" means a collection of software functions and/or data\nprepared so as to be conveniently linked with application programs\n(which use some of those functions and data) to form executables.\n\n  The \"Library\", below, refers to any such software library or work\nwhich has been distributed under these terms.  A \"work based on the\nLibrary\" means either the Library or any derivative work under\ncopyright law: that is to say, a work containing the Library or a\nportion of it, either verbatim or with modifications and/or translated\nstraightforwardly into another language.  (Hereinafter, translation is\nincluded without limitation in the term \"modification\".)\n\n  \"Source code\" for a work means the preferred form of the work for\nmaking modifications to it.  For a library, complete source code means\nall the source code for all modules it contains, plus any associated\ninterface definition files, plus the scripts used to control compilation\nand installation of the library.\n\n  Activities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning a program using the Library is not restricted, and output from\nsuch a program is covered only if its contents constitute a work based\non the Library (independent of the use of the Library in a tool for\nwriting it).  Whether that is true depends on what the Library does\nand what the program that uses the Library does.\n\n  1. You may copy and distribute verbatim copies of the Library's\ncomplete source code as you receive it, in any medium, provided that\nyou conspicuously and appropriately publish on each copy an\nappropriate copyright notice and disclaimer of warranty; keep intact\nall the notices that refer to this License and to the absence of any\nwarranty; and distribute a copy of this License along with the\nLibrary.\n\n  You may charge a fee for the physical act of transferring a copy,\nand you may at your option offer warranty protection in exchange for a\nfee.\n\n  2. You may modify your copy or copies of the Library or any portion\nof it, thus forming a work based on the Library, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) The modified work must itself be a software library.\n\n    b) You must cause the files modified to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    c) You must cause the whole of the work to be licensed at no\n    charge to all third parties under the terms of this License.\n\n    d) If a facility in the modified Library refers to a function or a\n    table of data to be supplied by an application program that uses\n    the facility, other than as an argument passed when the facility\n    is invoked, then you must make a good faith effort to ensure that,\n    in the event an application does not supply such function or\n    table, the facility still operates, and performs whatever part of\n    its purpose remains meaningful.\n\n    (For example, a function in a library to compute square roots has\n    a purpose that is entirely well-defined independent of the\n    application.  Therefore, Subsection 2d requires that any\n    application-supplied function or table used by this function must\n    be optional: if the application does not supply it, the square\n    root function must still compute square roots.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Library,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Library, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote\nit.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Library.\n\nIn addition, mere aggregation of another work not based on the Library\nwith the Library (or with a work based on the Library) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may opt to apply the terms of the ordinary GNU General Public\nLicense instead of this License to a given copy of the Library.  To do\nthis, you must alter all the notices that refer to this License, so\nthat they refer to the ordinary GNU General Public License, version 2,\ninstead of to this License.  (If a newer version than version 2 of the\nordinary GNU General Public License has appeared, then you can specify\nthat version instead if you wish.)  Do not make any other change in\nthese notices.\n\n  Once this change is made in a given copy, it is irreversible for\nthat copy, so the ordinary GNU General Public License applies to all\nsubsequent copies and derivative works made from that copy.\n\n  This option is useful when you wish to copy part of the code of\nthe Library into a program that is not a library.\n\n  4. You may copy and distribute the Library (or a portion or\nderivative of it, under Section 2) in object code or executable form\nunder the terms of Sections 1 and 2 above provided that you accompany\nit with the complete corresponding machine-readable source code, which\nmust be distributed under the terms of Sections 1 and 2 above on a\nmedium customarily used for software interchange.\n\n  If distribution of object code is made by offering access to copy\nfrom a designated place, then offering equivalent access to copy the\nsource code from the same place satisfies the requirement to\ndistribute the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  5. A program that contains no derivative of any portion of the\nLibrary, but is designed to work with the Library by being compiled or\nlinked with it, is called a \"work that uses the Library\".  Such a\nwork, in isolation, is not a derivative work of the Library, and\ntherefore falls outside the scope of this License.\n\n  However, linking a \"work that uses the Library\" with the Library\ncreates an executable that is a derivative of the Library (because it\ncontains portions of the Library), rather than a \"work that uses the\nlibrary\".  The executable is therefore covered by this License.\nSection 6 states terms for distribution of such executables.\n\n  When a \"work that uses the Library\" uses material from a header file\nthat is part of the Library, the object code for the work may be a\nderivative work of the Library even though the source code is not.\nWhether this is true is especially significant if the work can be\nlinked without the Library, or if the work is itself a library.  The\nthreshold for this to be true is not precisely defined by law.\n\n  If such an object file uses only numerical parameters, data\nstructure layouts and accessors, and small macros and small inline\nfunctions (ten lines or less in length), then the use of the object\nfile is unrestricted, regardless of whether it is legally a derivative\nwork.  (Executables containing this object code plus portions of the\nLibrary will still fall under Section 6.)\n\n  Otherwise, if the work is a derivative of the Library, you may\ndistribute the object code for the work under the terms of Section 6.\nAny executables containing that work also fall under Section 6,\nwhether or not they are linked directly with the Library itself.\n\n  6. As an exception to the Sections above, you may also combine or\nlink a \"work that uses the Library\" with the Library to produce a\nwork containing portions of the Library, and distribute that work\nunder terms of your choice, provided that the terms permit\nmodification of the work for the customer's own use and reverse\nengineering for debugging such modifications.\n\n  You must give prominent notice with each copy of the work that the\nLibrary is used in it and that the Library and its use are covered by\nthis License.  You must supply a copy of this License.  If the work\nduring execution displays copyright notices, you must include the\ncopyright notice for the Library among them, as well as a reference\ndirecting the user to the copy of this License.  Also, you must do one\nof these things:\n\n    a) Accompany the work with the complete corresponding\n    machine-readable source code for the Library including whatever\n    changes were used in the work (which must be distributed under\n    Sections 1 and 2 above); and, if the work is an executable linked\n    with the Library, with the complete machine-readable \"work that\n    uses the Library\", as object code and/or source code, so that the\n    user can modify the Library and then relink to produce a modified\n    executable containing the modified Library.  (It is understood\n    that the user who changes the contents of definitions files in the\n    Library will not necessarily be able to recompile the application\n    to use the modified definitions.)\n\n    b) Use a suitable shared library mechanism for linking with the\n    Library.  A suitable mechanism is one that (1) uses at run time a\n    copy of the library already present on the user's computer system,\n    rather than copying library functions into the executable, and (2)\n    will operate properly with a modified version of the library, if\n    the user installs one, as long as the modified version is\n    interface-compatible with the version that the work was made with.\n\n    c) Accompany the work with a written offer, valid for at\n    least three years, to give the same user the materials\n    specified in Subsection 6a, above, for a charge no more\n    than the cost of performing this distribution.\n\n    d) If distribution of the work is made by offering access to copy\n    from a designated place, offer equivalent access to copy the above\n    specified materials from the same place.\n\n    e) Verify that the user has already received a copy of these\n    materials or that you have already sent this user a copy.\n\n  For an executable, the required form of the \"work that uses the\nLibrary\" must include any data and utility programs needed for\nreproducing the executable from it.  However, as a special exception,\nthe materials to be distributed need not include anything that is\nnormally distributed (in either source or binary form) with the major\ncomponents (compiler, kernel, and so on) of the operating system on\nwhich the executable runs, unless that component itself accompanies\nthe executable.\n\n  It may happen that this requirement contradicts the license\nrestrictions of other proprietary libraries that do not normally\naccompany the operating system.  Such a contradiction means you cannot\nuse both them and the Library together in an executable that you\ndistribute.\n\n  7. You may place library facilities that are a work based on the\nLibrary side-by-side in a single library together with other library\nfacilities not covered by this License, and distribute such a combined\nlibrary, provided that the separate distribution of the work based on\nthe Library and of the other library facilities is otherwise\npermitted, and provided that you do these two things:\n\n    a) Accompany the combined library with a copy of the same work\n    based on the Library, uncombined with any other library\n    facilities.  This must be distributed under the terms of the\n    Sections above.\n\n    b) Give prominent notice with the combined library of the fact\n    that part of it is a work based on the Library, and explaining\n    where to find the accompanying uncombined form of the same work.\n\n  8. You may not copy, modify, sublicense, link with, or distribute\nthe Library except as expressly provided under this License.  Any\nattempt otherwise to copy, modify, sublicense, link with, or\ndistribute the Library is void, and will automatically terminate your\nrights under this License.  However, parties who have received copies,\nor rights, from you under this License will not have their licenses\nterminated so long as such parties remain in full compliance.\n\n  9. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Library or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Library (or any work based on the\nLibrary), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Library or works based on it.\n\n  10. Each time you redistribute the Library (or any work based on the\nLibrary), the recipient automatically receives a license from the\noriginal licensor to copy, distribute, link with or modify the Library\nsubject to these terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties with\nthis License.\n\n  11. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Library at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Library by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Library.\n\nIf any portion of this section is held invalid or unenforceable under any\nparticular circumstance, the balance of the section is intended to apply,\nand the section as a whole is intended to apply in other circumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  12. If the distribution and/or use of the Library is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Library under this License may add\nan explicit geographical distribution limitation excluding those countries,\nso that distribution is permitted only in or among countries not thus\nexcluded.  In such case, this License incorporates the limitation as if\nwritten in the body of this License.\n\n  13. The Free Software Foundation may publish revised and/or new\nversions of the Lesser General Public License from time to time.\nSuch new versions will be similar in spirit to the present version,\nbut may differ in detail to address new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Library\nspecifies a version number of this License which applies to it and\n\"any later version\", you have the option of following the terms and\nconditions either of that version or of any later version published by\nthe Free Software Foundation.  If the Library does not specify a\nlicense version number, you may choose any version ever published by\nthe Free Software Foundation.\n\n  14. If you wish to incorporate parts of the Library into other free\nprograms whose distribution conditions are incompatible with these,\nwrite to the author to ask for permission.  For software which is\ncopyrighted by the Free Software Foundation, write to the Free\nSoftware Foundation; we sometimes make exceptions for this.  Our\ndecision will be guided by the two goals of preserving the free status\nof all derivatives of our free software and of promoting the sharing\nand reuse of software generally.\n\n                            NO WARRANTY\n\n  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO\nWARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.\nEXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR\nOTHER PARTIES PROVIDE THE LIBRARY \"AS IS\" WITHOUT WARRANTY OF ANY\nKIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE\nLIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME\nTHE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN\nWRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY\nAND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU\nFOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR\nCONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE\nLIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING\nRENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A\nFAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF\nSUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH\nDAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n           How to Apply These Terms to Your New Libraries\n\n  If you develop a new library, and you want it to be of the greatest\npossible use to the public, we recommend making it free software that\neveryone can redistribute and change.  You can do so by permitting\nredistribution under these terms (or, alternatively, under the terms of the\nordinary General Public License).\n\n  To apply these terms, attach the following notices to the library.  It is\nsafest to attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least the\n\"copyright\" line and a pointer to where the full notice is found.\n\n    Reads an instagram export and creates html output that is browsable.\n    Copyright (C) 2025  Gregory Randall\n\n    This library is free software; you can redistribute it and/or\n    modify it under the terms of the GNU Lesser General Public\n    License as published by the Free Software Foundation; either\n    version 2.1 of the License, or (at your option) any later version.\n\n    This library is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU\n    Lesser General Public License for more details.\n\n    You should have received a copy of the GNU Lesser General Public\n    License along with this library; if not, write to the Free Software\n    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301\n    USA\n\nAlso add information on how to contact you by electronic and paper mail.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the library, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the\n  library `Frob' (a library for tweaking knobs) written by James Random\n  Hacker.\n\n  <signature of Ty Coon>, 1 April 1990\n  Ty Coon, President of Vice\n\nThat's all there is to it!\n"
  },
  {
    "path": "README.md",
    "content": "# Memento Mori - Instagram Archive Viewer\n\n<img align=\"right\" width=\"300\" hspace=\"20\" src=\"preview.gif\" alt=\"Memento Mori Interface Preview\">\n\n**Memento Mori** is a tool that converts your Instagram data export into a beautiful, standalone viewer that resembles the Instagram interface. The name \"Memento Mori\" (Latin for \"remember that you will die\") reflects the ephemeral nature of our digital content. You can see an example at https://gregr.org/instagram/.\n\nIf you find a bug that you're able to fix please create a pull request, otherwise create an issue!\n\n## Quick Start\nGet your Instagram data export zip, throw it in with this code, and run this command:\n```bash\ndocker compose run --rm memento-mori\n#Then open output/index.html in your browser\n```\n\n## ⚠️ IMPORTANT SECURITY WARNING ⚠️\n\n**DO NOT** share your raw Instagram export online! It contains sensitive data you probably don't want to share:\n\n- Phone numbers\n- Precise location data\n- Personal messages\n- Email addresses\n- Other private information\n\nOnly share the generated output folder after processing with this tool.\n\n## How It Works\nMemento Mori processes your Instagram data export and generates a static site with your posts and stories, copying all your media files into an organized structure that can be viewed offline or hosted on your own website.\n\n## Key Features\n- **Familiar Interface**: Grid layout with post details and carousel for multiple images\n- **Stories Support**: View your Instagram Stories with auto-progression and 9:16 aspect ratio display\n- **Media Optimization**: Converts images to WebP, generates thumbnails, and supports video playback\n- **Organization**: Sorts posts by various criteria with shareable links to specific content\n- **Profile Information**: Displays bio, website, and follower count from your Instagram profile\n- **Technical Improvements**:\n  - Fixes encoding issues and mislabeled file formats\n  - Shortens filenames for smaller HTML size\n  - Processes files in parallel with a responsive design that works on all devices\n  - Robust error handling with verbose debugging option\n\n## How to Use Memento Mori\n\n### 1. Get Your Instagram Data\n1. Request and download your Instagram data archive\n2. Place the zip within the folder of this repo\n\n### 2. Preferred Method: Using Docker (Easiest)\nDocker Compose is the easiest way to run Memento Mori without installing any dependencies. Many thanks to [CarsonDavis](https://github.com/CarsonDavis) for building out all the dockerizing code (as well as generally making my code better):\n```bash\n# Build the Docker image\ndocker compose build\n\n# Run with default settings\ndocker compose run --rm memento-mori\n\n# Run with specific arguments\ndocker compose run --rm memento-mori --output /output/my-site --quality 90\n\n# Add Google Analytics tracking\ndocker compose run --rm memento-mori --gtag-id G-DX1ZWTC9NZ\n\n# Serve the output folder locally to preview in your browser\npython3 -m http.server -d output\n```\n\nBy default, Docker will:\n- Search for exports in the project directory\n- Output the generated site to the './output' directory\n\n### 3. Alternative Method: Direct Python Installation\nIf you prefer running the tool directly without Docker:\n```bash\n# Install package and dependencies\npip install -e .\n\n# Or install dependencies manually\npip install ftfy==6.3.1 Jinja2==3.0.3 MarkupSafe==2.1.5 opencv_python==4.10.0.84 Pillow==11.1.0 tqdm==4.67.1 python_magic==0.4.27\n\n# Run the CLI\npython -m memento_mori.cli\n\n# Serve the output folder locally to preview in your browser\npython3 -m http.server -d output\n```\n\n### CLI Arguments\nThe CLI supports the following arguments:\n```\nOptions:\n--input PATH Path to data (ZIP or folder). If not specified, auto-detection will be used.\n--output PATH Output directory for generated website [default: ./output]\n--threads INTEGER Number of parallel processing threads [default: core count - 1]\n--search-dir PATH Directory to search for exports when auto-detecting [default: current directory]\n--quality INTEGER WebP conversion quality (1-100) [default: 70]\n--max-dimension INTEGER Maximum dimension for images in pixels [default: 1920]\n--thumbnail-size WxH Size of thumbnails [default: 292x292]\n--no-auto-detect Disable auto-detection (requires --input to be specified)\n--gtag-id ID     Google Analytics tag ID (e.g., 'G-DX1ZWTC9NZ') to add tracking to the generated site\n--verbose, -v    Enable verbose output for debugging\n```\n\nNote: Auto-detection is enabled by default and will look for exports in the current directory. Use `--no-auto-detect` if you want to disable this feature and specify an input path manually.\n\n### Example Commands\n```bash\n# Auto-detect export in current directory\npython -m memento_mori.cli\n\n# Specify input file/folder and output directory\npython -m memento_mori.cli --input path/to/export.zip --output my-site\n\n# Use specific number of threads and image quality\npython -m memento_mori.cli --threads 8 --quality 90\n\n# Specify search directory for auto-detection\npython -m memento_mori.cli --search-dir ~/Downloads\n\n# Use custom thumbnail size\npython -m memento_mori.cli --thumbnail-size 400x400\n\n# Specify maximum image dimension\npython -m memento_mori.cli --max-dimension 1600\n\n# Disable auto-detection (requires specifying input)\npython -m memento_mori.cli --no-auto-detect --input path/to/export.zip\n\n# Add Google Analytics tracking\npython -m memento_mori.cli --gtag-id G-DX1ZWTC9NZ\n\n# Enable verbose debugging output\npython -m memento_mori.cli --verbose\n```\n\n## Viewing Your Generated Site\nAfter the tool finishes processing your Instagram data:\n1. The website will be generated in the output directory (default: ./output)\n2. Open the `index.html` file in this directory with your web browser to view your Instagram archive\n3. Click on \"stories\" in your profile stats to view your Stories archive\n4. You can also upload the entire output directory to a web hosting service to share it online\n\n## PHP Version (Alternative)\nFor those who prefer the deprecated PHP implementation, there are a few notes in the deprecated_php_utility folder, but basically extract your data into the folder with the php file, and run\n```bash\n# Run from command line\nphp index.php\n```\n\n## Why This Exists\nWhen requesting your data from Instagram, the export you receive contains your content but in a format that's intentionally difficult to navigate and enjoy. Memento Mori solves this problem by transforming your archive into an intuitive, familiar interface that brings your memories back to life.\n\nInstagram, like many social platforms, has undergone significant \"enshittification\" - a term coined to describe how platforms evolve:\n\n1. First, they attract users with a quality experience\n2. Then, they leverage their position to extract data and attention\n3. Finally, they degrade the user experience to maximize profit\n"
  },
  {
    "path": "deprecated_php_utility/index.php",
    "content": "<?php\n\n// Create distribution directory if it doesn't exist\nif (!file_exists('distribution')) {\n    mkdir('distribution', 0755, true);\n}\n\n/**\n * Copy media files to the distribution folder\n * \n * @param array $post_data The post data containing media URLs\n * @param string $profile_picture The profile picture URL\n */\nfunction copy_media_files($post_data, $profile_picture) {\n  // Create media directories in distribution folder\n  $media_dirs = [\n      'distribution/media',\n      'distribution/media/posts',\n      'distribution/media/other',\n      'distribution/thumbnails'\n  ];\n  \n  foreach ($media_dirs as $dir) {\n      if (!file_exists($dir)) {\n          mkdir($dir, 0755, true);\n      }\n  }\n  \n  // Copy profile picture\n  copy_file_to_distribution($profile_picture);\n  \n  // Generate thumbnail for profile picture\n  generate_thumbnail($profile_picture, $profile_picture);\n  \n  // Copy all post media\n  $total_media = 0;\n  $processed_media = 0;\n  \n  // Count total media files first\n  foreach ($post_data as $post) {\n      $total_media += count($post['media']);\n  }\n  \n  fwrite(STDERR, \"Generating thumbnails for $total_media media files...\\n\");\n  \n  // Process each media file\n  foreach ($post_data as $post) {\n      foreach ($post['media'] as $media_url) {\n          copy_file_to_distribution($media_url);\n          $processed_media++;\n          \n          // Show progress\n          if ($processed_media % 10 === 0 || $processed_media === $total_media) {\n              $percent = round(($processed_media / $total_media) * 100);\n              fwrite(STDERR, \"Progress: $processed_media/$total_media ($percent%)\\n\");\n          }\n      }\n  }\n  \n  // Count how many thumbnails and WebP conversions were successfully generated\n  $thumbnail_count = 0;\n  $webp_count = 0;\n  $total_size_original = 0;\n  $total_size_webp = 0;\n  \n  if (is_dir('distribution/thumbnails')) {\n      $thumbnail_count = count(glob('distribution/thumbnails/*.webp'));\n  }\n  \n  // Count WebP conversions and calculate space savings\n  foreach ($post_data as $post) {\n      foreach ($post['media'] as $media_url) {\n          if (preg_match('/\\.(jpg|jpeg|png|gif)$/i', $media_url)) {\n              $original_path = $media_url;\n              $webp_path = preg_replace('/\\.(jpg|jpeg|png|gif)$/i', '.webp', $media_url);\n              \n              if (file_exists('distribution/' . $webp_path)) {\n                  $webp_count++;\n                  \n                  // Calculate size difference if original exists\n                  if (file_exists($original_path)) {\n                      $original_size = filesize($original_path);\n                      $webp_size = filesize('distribution/' . $webp_path);\n                      $total_size_original += $original_size;\n                      $total_size_webp += $webp_size;\n                  }\n              }\n          }\n      }\n  }\n  \n  // Calculate total space savings\n  $space_saved_mb = ($total_size_original - $total_size_webp) / (1024 * 1024);\n  \n  fwrite(STDERR, \"All media files and thumbnails processed.\\n\");\n  fwrite(STDERR, \"Successfully generated $thumbnail_count thumbnails.\\n\");\n  fwrite(STDERR, \"Successfully converted $webp_count images to WebP format.\\n\");\n  \n  if ($total_size_original > 0) {\n      $percentage_saved = (($total_size_original - $total_size_webp) / $total_size_original * 100);\n      fwrite(STDERR, sprintf(\"Total space saved: %.2f MB (%.1f%%)\\n\", \n          $space_saved_mb, \n          $percentage_saved\n      ));\n  } else {\n      fwrite(STDERR, sprintf(\"Total space saved: %.2f MB (0.0%%)\\n\", $space_saved_mb));\n  }\n  \n  echo \"Media files copied to distribution folder.\\n\";\n}\n\n/**\n* Copy a single file to the distribution folder, maintaining its path structure\n* \n* @param string $file_path The path to the file\n*/\nfunction copy_file_to_distribution($file_path) {\n  // Skip if it's already a data URI\n  if (strpos($file_path, 'data:image') === 0) {\n      return;\n  }\n  \n  $source = $file_path;\n  $destination = 'distribution/' . $file_path;\n  \n  // Create directory structure if it doesn't exist\n  $dir = dirname($destination);\n  if (!file_exists($dir)) {\n      mkdir($dir, 0755, true);\n  }\n  \n  // Check if it's an image file that can be converted to WebP\n  $is_image = preg_match('/\\.(jpg|jpeg|png|gif)$/i', $file_path);\n  $is_video = preg_match('/\\.(mp4|mov|avi|webm)$/i', $file_path);\n  \n  if ($is_image && file_exists($source)) {\n      // Convert image to WebP for better compression\n      $webp_destination = preg_replace('/\\.(jpg|jpeg|png|gif)$/i', '.webp', $destination);\n      convert_to_webp($source, $webp_destination);\n      \n      // Generate thumbnail for this file\n      generate_thumbnail($source, $file_path);\n  } else if (file_exists($source)) {\n      // Copy the file as is (for videos and other file types)\n      copy($source, $destination);\n      \n      // Generate thumbnail for this file\n      generate_thumbnail($source, $file_path);\n  }\n}\n\n/**\n * Convert an image to WebP format without cropping\n * \n * @param string $source_path The source image path\n * @param string $destination_path The destination WebP path\n * @return bool True if successful, false otherwise\n */\nfunction convert_to_webp($source_path, $destination_path) {\n    try {\n        // Detect file type by examining file contents\n        $file_info = new finfo(FILEINFO_MIME_TYPE);\n        $mime_type = $file_info->file($source_path);\n        \n        // Create image resource based on mime type\n        $source_image = null;\n        \n        switch ($mime_type) {\n            case 'image/jpeg':\n                $source_image = @imagecreatefromjpeg($source_path);\n                break;\n            case 'image/png':\n                $source_image = @imagecreatefrompng($source_path);\n                // Preserve transparency for PNG\n                if ($source_image) {\n                    imagepalettetotruecolor($source_image);\n                    imagealphablending($source_image, true);\n                    imagesavealpha($source_image, true);\n                }\n                break;\n            case 'image/gif':\n                $source_image = @imagecreatefromgif($source_path);\n                break;\n            default:\n                // Try to load as JPEG first, then PNG, then GIF as fallbacks\n                $source_image = @imagecreatefromjpeg($source_path);\n                if (!$source_image) {\n                    $source_image = @imagecreatefrompng($source_path);\n                    if ($source_image) {\n                        imagepalettetotruecolor($source_image);\n                        imagealphablending($source_image, true);\n                        imagesavealpha($source_image, true);\n                    }\n                }\n                if (!$source_image) {\n                    $source_image = @imagecreatefromgif($source_path);\n                }\n                break;\n        }\n        \n        if (!$source_image) {\n            fwrite(STDERR, \"Failed to create image resource for conversion: \" . $source_path . \"\\n\");\n            // Fall back to copying the original file\n            copy($source_path, str_replace('.webp', '.jpg', $destination_path));\n            return false;\n        }\n        \n        // Save as WebP with 80% quality (good balance between quality and file size)\n        $result = imagewebp($source_image, $destination_path, 80);\n        \n        // Clean up\n        imagedestroy($source_image);\n        \n        if ($result) {\n            // Check if the WebP file is actually smaller than the original\n            $original_size = filesize($source_path);\n            $webp_size = filesize($destination_path);\n            \n            if ($webp_size > 0 && $webp_size < $original_size) {\n                fwrite(STDERR, \"Converted to WebP: \" . $source_path . \" (saved \" . \n                       round(($original_size - $webp_size) / 1024, 2) . \" KB)\\n\");\n                return true;\n            } else {\n                // If WebP is larger or failed, use the original file\n                unlink($destination_path);\n                copy($source_path, str_replace('.webp', '.jpg', $destination_path));\n                fwrite(STDERR, \"WebP larger than original, using original: \" . $source_path . \"\\n\");\n                return false;\n            }\n        } else {\n            // If WebP conversion failed, use the original file\n            copy($source_path, str_replace('.webp', '.jpg', $destination_path));\n            fwrite(STDERR, \"WebP conversion failed, using original: \" . $source_path . \"\\n\");\n            return false;\n        }\n    } catch (Exception $e) {\n        fwrite(STDERR, \"Error converting to WebP: \" . $e->getMessage() . \"\\n\");\n        // Fall back to copying the original file\n        copy($source_path, str_replace('.webp', '.jpg', $destination_path));\n        return false;\n    }\n}\n\n/**\n * Generate a thumbnail for an image or video file\n * \n * @param string $source_path The source file path\n * @param string $relative_path The relative path for naming the thumbnail\n * @return string|null The path to the generated thumbnail or null if failed\n */\nfunction generate_thumbnail($source_path, $relative_path) {\n    // Create thumbnails directory if it doesn't exist\n    $thumbs_dir = 'distribution/thumbnails';\n    if (!file_exists($thumbs_dir)) {\n        mkdir($thumbs_dir, 0755, true);\n    }\n    \n    // Generate a unique filename for the thumbnail based on the original path\n    $thumb_filename = md5($relative_path) . '.webp';\n    $thumb_path = $thumbs_dir . '/' . $thumb_filename;\n    \n    // Skip if thumbnail already exists\n    if (file_exists($thumb_path)) {\n        return $thumb_path;\n    }\n    \n    // Target dimensions\n    $target_width = 292;\n    $target_height = 292;\n    \n    fwrite(STDERR, \"Generating thumbnail for: $relative_path\\n\");\n    \n    try {\n        // Check if file exists\n        if (!file_exists($source_path)) {\n            fwrite(STDERR, \"File not found: $source_path\\n\");\n            return null;\n        }\n        \n        // Detect file type by examining file contents\n        $file_info = new finfo(FILEINFO_MIME_TYPE);\n        $mime_type = $file_info->file($source_path);\n        \n        // Determine if it's a video based on mime type\n        $is_video = (strpos($mime_type, 'video/') === 0);\n        \n        // For HEIC files (often incorrectly labeled)\n        $is_heic = false;\n        if (strpos($mime_type, 'application/octet-stream') === 0) {\n            // Check for HEIC signature\n            $file_header = file_get_contents($source_path, false, null, 0, 12);\n            if (strpos($file_header, 'ftypheic') !== false || \n                strpos($file_header, 'ftypmif1') !== false || \n                strpos($file_header, 'ftyphevc') !== false) {\n                $is_heic = true;\n            }\n        }\n        \n        if ($is_video) {\n            // For videos, try to use FFmpeg to extract a frame\n            if (function_exists('exec')) {\n                $temp_jpg = tempnam(sys_get_temp_dir(), 'thumb') . '.jpg';\n                // Extract a frame at 1 second mark\n                exec(\"ffmpeg -i \\\"$source_path\\\" -ss 00:00:01 -vframes 1 -vf \\\"scale=$target_width:$target_height:force_original_aspect_ratio=decrease,pad=$target_width:$target_height:(ow-iw)/2:(oh-ih)/2:color=black\\\" \\\"$temp_jpg\\\" 2>&1\", $output, $return_var);\n                \n                if ($return_var !== 0) {\n                    fwrite(STDERR, \"FFmpeg error: \" . implode(\"\\n\", $output) . \"\\n\");\n                    return null;\n                }\n                \n                // Convert the extracted frame to WebP\n                if (function_exists('imagecreatefromjpeg') && function_exists('imagewebp')) {\n                    $image = imagecreatefromjpeg($temp_jpg);\n                    imagewebp($image, $thumb_path, 80);\n                    imagedestroy($image);\n                    unlink($temp_jpg); // Clean up temp file\n                    return $thumb_path;\n                }\n            }\n            \n            // If FFmpeg fails or is not available, use a placeholder\n            fwrite(STDERR, \"Could not generate video thumbnail for: $relative_path\\n\");\n            return null;\n        } else if ($is_heic) {\n            // For HEIC files, try to use ImageMagick if available\n            if (function_exists('exec')) {\n                $temp_jpg = tempnam(sys_get_temp_dir(), 'thumb') . '.jpg';\n                exec(\"convert \\\"$source_path\\\" \\\"$temp_jpg\\\" 2>&1\", $output, $return_var);\n                \n                if ($return_var !== 0) {\n                    fwrite(STDERR, \"ImageMagick error for HEIC: \" . implode(\"\\n\", $output) . \"\\n\");\n                    return null;\n                }\n                \n                // Now process the converted JPG\n                if (file_exists($temp_jpg)) {\n                    $source_image = imagecreatefromjpeg($temp_jpg);\n                    if (!$source_image) {\n                        fwrite(STDERR, \"Failed to create image from converted HEIC: $relative_path\\n\");\n                        unlink($temp_jpg);\n                        return null;\n                    }\n                    \n                    // Process the image (resize and save as WebP)\n                    $result = process_and_save_image($source_image, $thumb_path, $target_width, $target_height);\n                    unlink($temp_jpg); // Clean up temp file\n                    return $result;\n                }\n            }\n            \n            fwrite(STDERR, \"Could not convert HEIC file: $relative_path\\n\");\n            return null;\n        } else {\n            // For images, use GD library\n            if (!function_exists('imagecreatefromjpeg') || !function_exists('imagewebp')) {\n                fwrite(STDERR, \"GD library with WebP support is required\\n\");\n                return null;\n            }\n            \n            // Create image resource based on mime type\n            $source_image = null;\n            \n            switch ($mime_type) {\n                case 'image/jpeg':\n                    $source_image = @imagecreatefromjpeg($source_path);\n                    break;\n                case 'image/png':\n                    $source_image = @imagecreatefrompng($source_path);\n                    break;\n                case 'image/gif':\n                    $source_image = @imagecreatefromgif($source_path);\n                    break;\n                case 'image/webp':\n                    $source_image = @imagecreatefromwebp($source_path);\n                    break;\n                default:\n                    // Try to load as JPEG first, then PNG, then GIF as fallbacks\n                    $source_image = @imagecreatefromjpeg($source_path);\n                    if (!$source_image) {\n                        $source_image = @imagecreatefrompng($source_path);\n                    }\n                    if (!$source_image) {\n                        $source_image = @imagecreatefromgif($source_path);\n                    }\n                    if (!$source_image) {\n                        $source_image = @imagecreatefromwebp($source_path);\n                    }\n                    break;\n            }\n            \n            if (!$source_image) {\n                fwrite(STDERR, \"Failed to create image resource for: $relative_path (MIME: $mime_type)\\n\");\n                return null;\n            }\n            \n            return process_and_save_image($source_image, $thumb_path, $target_width, $target_height);\n        }\n    } catch (Exception $e) {\n        fwrite(STDERR, \"Error generating thumbnail: \" . $e->getMessage() . \"\\n\");\n        return null;\n    }\n    \n    return null;\n}\n\n/**\n * Process an image resource and save it as a WebP thumbnail\n * \n * @param resource $source_image The source image resource\n * @param string $thumb_path The path to save the thumbnail\n * @param int $target_width The target width\n * @param int $target_height The target height\n * @return string|null The path to the generated thumbnail or null if failed\n */\nfunction process_and_save_image($source_image, $thumb_path, $target_width, $target_height) {\n    try {\n        // Get original dimensions\n        $original_width = imagesx($source_image);\n        $original_height = imagesy($source_image);\n        \n        // Create the final square thumbnail\n        $thumb_image = imagecreatetruecolor($target_width, $target_height);\n        \n        // Fill with white background\n        $white = imagecolorallocate($thumb_image, 255, 255, 255);\n        imagefilledrectangle($thumb_image, 0, 0, $target_width, $target_height, $white);\n        \n        // Calculate dimensions for cropping to ensure 1:1 aspect ratio\n        // We'll take the center portion of the image\n        if ($original_width > $original_height) {\n            // Landscape image: crop from the center horizontally\n            $src_x = ($original_width - $original_height) / 2;\n            $src_y = 0;\n            $src_w = $original_height;\n            $src_h = $original_height;\n        } else {\n            // Portrait image: crop from the center vertically\n            $src_x = 0;\n            $src_y = ($original_height - $original_width) / 2;\n            $src_w = $original_width;\n            $src_h = $original_width;\n        }\n        \n        // Copy and resize the cropped portion directly to the thumbnail\n        imagecopyresampled(\n            $thumb_image, $source_image,\n            0, 0, $src_x, $src_y,\n            $target_width, $target_height, $src_w, $src_h\n        );\n        \n        // Save as WebP\n        imagewebp($thumb_image, $thumb_path, 80);\n        \n        // Clean up\n        imagedestroy($source_image);\n        imagedestroy($thumb_image);\n        \n        return $thumb_path;\n    } catch (Exception $e) {\n        fwrite(STDERR, \"Error processing image: \" . $e->getMessage() . \"\\n\");\n        return null;\n    }\n}\n\n\n\nfunction render_instagram_grid($post_data, $lazy_after = 30) {\n    $output = '';\n    \n    // Process each post\n    $i=1;\n    foreach ($post_data as $timestamp => $post) {\n        if($i > $lazy_after){\n            $lazy_load = ' loading=\"lazy\"';\n        } else {\n            $lazy_load = '';\n        }\n        $index = $post['post_index'];\n        $media_count = count($post['media']);\n        \n        // Determine which media to use for the grid thumbnail\n        $display_media = '';\n        $is_video = false;\n        \n        if (isset($post['media'][0])) {\n            $first_media = $post['media'][0];\n            $original_media = $first_media;\n            $display_media = $first_media;\n            \n            // Check if first media is a video\n            $is_video = preg_match('/\\.(mp4|mov|avi|webm)$/i', $first_media);\n            \n            // Check if we have a thumbnail for this media\n            $thumb_filename = md5($first_media) . '.webp';\n            $thumb_path = 'thumbnails/' . $thumb_filename;\n            \n            if (file_exists('distribution/' . $thumb_path)) {\n                // Use the thumbnail instead of the original\n                $display_media = $thumb_path;\n                fwrite(STDERR, \"Using thumbnail for: $first_media\\n\");\n            } else {\n                // Check if we have a WebP version of the original image\n                if (!$is_video) {\n                    $webp_path = preg_replace('/\\.(jpg|jpeg|png|gif)$/i', '.webp', $first_media);\n                    if (file_exists('distribution/' . $webp_path)) {\n                        $display_media = $webp_path;\n                        fwrite(STDERR, \"Using WebP version for: $first_media\\n\");\n                    }\n                }\n                \n                // If it's a video, look for a thumbnail among all media items\n                if ($is_video) {\n                    $found_thumbnail = false;\n                    \n                    // First check if there are any image files in the post's media that could be thumbnails\n                    foreach ($post['media'] as $media_item) {\n                        if (preg_match('/\\.(jpg|jpeg|png|webp|gif)$/i', $media_item)) {\n                            // Check if we have a thumbnail for this image\n                            $img_thumb_filename = md5($media_item) . '.webp';\n                            $img_thumb_path = 'thumbnails/' . $img_thumb_filename;\n                            \n                            if (file_exists('distribution/' . $img_thumb_path)) {\n                                $display_media = $img_thumb_path;\n                            } else {\n                                $display_media = $media_item;\n                            }\n                            $found_thumbnail = true;\n                            break;\n                        }\n                    }\n                    \n                    // If no thumbnail found, use a better SVG placeholder\n                    if (!$found_thumbnail) {\n                        // Create a simple SVG with a play button\n                        $svg = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"400\" viewBox=\"0 0 400 400\">';\n                        $svg .= '<rect width=\"400\" height=\"400\" fill=\"#333333\"/>';\n                        $svg .= '<circle cx=\"200\" cy=\"200\" r=\"60\" fill=\"#ffffff\" fill-opacity=\"0.8\"/>';\n                        $svg .= '<polygon points=\"180,160 180,240 240,200\" fill=\"#333333\"/>';\n                        $svg .= '</svg>';\n                        \n                        // Encode the SVG properly for use in an img src attribute\n                        $display_media = 'data:image/svg+xml;base64,' . base64_encode($svg);\n                    }\n                }\n            }\n        }\n        \n        $output .= '        <div class=\"grid-item\" data-index=\"' . $index . '\">' . \"\\n\";\n        $output .= '          <img src=\"' . $display_media . '\" alt=\"Instagram post\"'.$lazy_load.'>' . \"\\n\";\n        \n        // Add video indicator if it's a video\n        if ($is_video) {\n            $output .= '          <div class=\"video-indicator\">▶ Video</div>' . \"\\n\";\n        }\n        \n        if ($media_count > 1) {\n            $output .= '          <div class=\"multi-indicator\">⊞ ' . $media_count . '</div>' . \"\\n\";\n        } elseif (isset($post['Likes']) && $post['Likes'] !== '') {\n            $output .= '          <div class=\"likes-indicator\">♥ ' . $post['Likes'] . '</div>' . \"\\n\";\n        }\n        \n        $output .= '        </div>' . \"\\n\";\n        $i++;\n    }\n    \n    return $output;\n}\n\ndate_default_timezone_set(\"America/New_York\");\n\n\n$personal_data = file_get_contents(\"personal_information/personal_information/personal_information.json\");\n$personal_data = json_decode($personal_data,true);\n$profile_picture = $personal_data['profile_user'][0]['media_map_data']['Profile Photo']['uri'];\n$user_name = $personal_data['profile_user'][0][\"string_map_data\"][\"Username\"][\"value\"];\nunset($personal_data);\n\n//echo \"profile picture: $profile_picture\n//username: $user_name\\n\";\n\n\n$location_data = file_get_contents(\"personal_information/information_about_you/profile_based_in.json\");\n$location_data  = json_decode($location_data ,true);\n$location = $location_data['inferred_data_primary_location'][0]['string_map_data']['City Name']['value'];\nunset($location_data);\n\n//echo \"location: $location\\n\";\n\n\n// Function to search for posts_1.json file recursively\nfunction find_posts_json() {\n    $standard_path = 'your_instagram_activity/content/posts_1.json';\n    \n    // First check the standard location\n    if (file_exists($standard_path)) {\n        return $standard_path;\n    }\n    \n    // If not found, search recursively\n    fwrite(STDERR, \"posts_1.json not found in standard location, searching directories...\\n\");\n    \n    $found_files = [];\n    $iterator = new RecursiveIteratorIterator(\n        new RecursiveDirectoryIterator('.', RecursiveDirectoryIterator::SKIP_DOTS)\n    );\n    \n    foreach ($iterator as $file) {\n        if ($file->getFilename() === 'posts_1.json') {\n            $found_files[] = $file->getPathname();\n        }\n    }\n    \n    if (empty($found_files)) {\n        fwrite(STDERR, \"ERROR: Could not find posts_1.json anywhere in the directory structure.\\n\");\n        return false;\n    }\n    \n    // If multiple files found, use the one that seems most likely\n    if (count($found_files) > 1) {\n        fwrite(STDERR, \"Found multiple posts_1.json files:\\n\");\n        foreach ($found_files as $index => $path) {\n            fwrite(STDERR, \"  [$index] $path\\n\");\n        }\n        \n        // Try to find the one in a directory with \"content\" or \"activity\" in the path\n        foreach ($found_files as $path) {\n            if (strpos($path, 'content') !== false || strpos($path, 'activity') !== false) {\n                fwrite(STDERR, \"Selected: $path\\n\");\n                return $path;\n            }\n        }\n        \n        // If no preferred path found, use the first one\n        fwrite(STDERR, \"Selected: {$found_files[0]}\\n\");\n        return $found_files[0];\n    }\n    \n    fwrite(STDERR, \"Found posts_1.json at: {$found_files[0]}\\n\");\n    return $found_files[0];\n}\n\n// Load and decode the JSON files\n$insights_data = file_get_contents('logged_information/past_instagram_insights/posts.json');\n$insights_data = json_decode($insights_data, true);\n\n$posts_json_path = find_posts_json();\nif (!$posts_json_path) {\n    die(\"ERROR: Could not find the posts_1.json file. Please ensure your Instagram data is properly extracted.\");\n}\n$post_data = file_get_contents($posts_json_path);\n$post_data = json_decode($post_data, true);\n\n// Create an indexed array of insights data using creation_timestamp as key\n$indexed_insights = [];\nforeach ($insights_data['organic_insights_posts'] as $insight) {\n    $timestamp = $insight['media_map_data']['Media Thumbnail']['creation_timestamp'];\n    $indexed_insights[$timestamp] = $insight;\n}\n\n// Combine the data\n$combined_data = [];\nforeach ($post_data as $post) {\n    // Get the timestamp from the first media item (since a post might have multiple media items)\n    $timestamp = $post['media'][0]['creation_timestamp'];\n    \n    // Create the combined post object\n    $combined_post = [\n        'post_data' => $post,\n        'insights' => isset($indexed_insights[$timestamp]) ? $indexed_insights[$timestamp] : null\n    ];\n    \n    // Add to combined data array\n    $combined_data[] = $combined_post;\n}\n\nunset($post_data);\nunset($insights_data);\nunset($indexed_insights);\n\nfunction extractRelevantData($combined_data) {\n    $simplified_data = [];\n\n    foreach ($combined_data as $index => $item) {\n        // Initialize a new post entry\n        $post_entry = [\n            'post_index' => $index,\n            'media' => [],\n            'creation_timestamp_unix' => \"\",\n            'creation_timestamp_readable' => \"\",\n            'title' => \"\",\n            'Impressions' => \"\",\n            'Likes' => \"\",\n            'Comments' => \"\"\n        ];\n        \n        // Extract post-level data\n        if (isset($item['post_data'])) {\n            if (isset($item['post_data']['creation_timestamp'])) {\n                $post_entry['creation_timestamp_unix'] = $item['post_data']['creation_timestamp'];\n            } elseif (isset($item['post_data']['media'][0]['creation_timestamp'])) {\n                // Fallback to first media item timestamp if post timestamp not available\n                $post_entry['creation_timestamp_unix'] = $item['post_data']['media'][0]['creation_timestamp'];\n            }\n\n   \n            $post_entry['creation_timestamp_readable'] = gmdate(\"F j, Y \\a\\\\t g:i A\", $post_entry['creation_timestamp_unix']);\n\n            \n            if (isset($item['post_data']['title'])) {\n                $post_entry['title'] = $item['post_data']['title'];\n            }\n            \n            // Extract media URIs\n            if (isset($item['post_data']['media'])) {\n                foreach ($item['post_data']['media'] as $media) {\n                    $post_entry['media'][] = $media['uri'] ?? \"\";\n                }\n            }\n        }\n        \n        // Get insights data if available\n        if (isset($item['insights']) && isset($item['insights']['string_map_data'])) {\n            $insights = $item['insights']['string_map_data'];\n            \n            // Extract specific metrics and ensure they're integers or blank\n            if (isset($insights['Impressions'])) {\n                $impressions = $insights['Impressions']['value'] ?? \"\";\n                // Validate and convert to integer if numeric, otherwise leave blank\n                $post_entry['Impressions'] = is_numeric($impressions) ? (int)$impressions : \"\";\n            }\n            \n            if (isset($insights['Likes'])) {\n                $likes = $insights['Likes']['value'] ?? \"\";\n                // Validate and convert to integer if numeric, otherwise leave blank\n                $post_entry['Likes'] = is_numeric($likes) ? (int)$likes : \"\";\n            }\n            \n            if (isset($insights['Comments'])) {\n                $comments = $insights['Comments']['value'] ?? \"\";\n                // Validate and convert to integer if numeric, otherwise leave blank\n                $post_entry['Comments'] = is_numeric($comments) ? (int)$comments : \"\";\n            }\n        }\n        \n        $simplified_data[$post_entry['creation_timestamp_unix']] = $post_entry;\n        \n    }\n\n    krsort($simplified_data);\n    return $simplified_data;\n}\n\n$post_data = extractRelevantData($combined_data);\nunset($combined_data);\n\n\n\necho \"<br><br><br>\";\n\n// Assuming your array is stored in $post_data\n$keys = array_keys($post_data);\n\n// Get first and last keys\n$first_key = reset($keys); // Or $keys[0]\n$last_key = end($keys);    // Or $keys[count($keys) - 1]\n\n// Get timestamps from first and last elements\n$last_timestamp = gmdate(\"F Y\",$post_data[$first_key]['creation_timestamp_unix']);\n$first_timestamp = gmdate(\"F Y\",$post_data[$last_key]['creation_timestamp_unix']);\n\n//echo\"<pre>\" . print_r($post_data[],true) .\"</pre>\";\n\n\n\n?>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Memento Mori</title>\n    <link rel=\"stylesheet\" href=\"style.css\">\n    <!-- Script to make post data available to JavaScript -->\n    <script>\n        window.postData = <?php echo json_encode($post_data); ?>;\n    </script>\n    <?php\n    // Include the modal.js content directly in the output\n    echo '<script>';\n    echo file_get_contents('modal.js');\n    echo '</script>';\n    ?>\n  </head>\n  <body class=\"vsc-initialized\">\n    <header>\n      <div class=\"header-content\">\n        <a href=\"https://github.com/greg-randall/memento-mori\" class=\"logo\">Memento Mori</a>\n        <div class=\"date-range-header\" id=\"date-range-header\"><?php echo \"$first_timestamp - $last_timestamp\"; ?></div>\n      </div>\n    </header>\n    <main>\n      <div class=\"loading\" id=\"loadingPosts\" style=\"display: none;\"> Loading posts... </div>\n      <div class=\"profile-info\">\n        <div class=\"profile-picture\">\n          <img alt=\"Profile Picture\" src=\"<?php echo $profile_picture; ?>\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n        </div>\n        <div class=\"profile-details\">\n          <h1 id=\"username\"><?php echo $user_name; ?></h1>\n          <div class=\"stats\">\n            <div class=\"stat\">\n              <span class=\"stat-count\" id=\"post-count\"><?php echo count($post_data); ?></span> posts\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"sort-options\">\n        <div class=\"sort-row\">\n          <a href=\"#\" class=\"sort-link active\" data-sort=\"newest\">Newest</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"oldest\">Oldest</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-likes\">Most Likes</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-comments\">Most Comments</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-views\">Most Views</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"random\">Random</a>\n        </div>\n      </div>\n      <div class=\"posts-grid\" id=\"postsGrid\">\n        <?php echo render_instagram_grid($post_data); ?>\n        </div>\n       \n    </main>\n\n    <!-- Modal for post details -->\n    <div class=\"post-modal\" id=\"postModal\">\n        <div class=\"close-modal\" id=\"closeModal\">✕</div>\n        <div class=\"modal-nav modal-prev\" id=\"modalPrev\">❮</div>\n        <div class=\"modal-nav modal-next\" id=\"modalNext\">❯</div>\n        <div class=\"post-modal-content\">\n            <div class=\"post-media\" id=\"postMedia\"></div>\n            <div class=\"post-info\">\n                <div class=\"post-header\">\n                    <div class=\"post-user\" id=\"postUserPic\">\n                        <img src=\"<?php echo $profile_picture; ?>\" alt=\"Profile\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n                    </div>\n                    <div class=\"post-username\" id=\"postUsername\"><?php echo $user_name; ?></div>\n                </div>\n                <div class=\"post-caption\" id=\"postCaption\"></div>\n                <div class=\"post-stats\" id=\"postStats\"></div>\n                <div class=\"post-date\" id=\"postDate\"></div>\n            </div>\n        </div>\n    </div>\n  </body>\n</html>\n\n<?php\n// Copy media files and generate thumbnails first\ncopy_media_files($post_data, $profile_picture);\n\n// Now start output buffering to capture HTML after thumbnails are generated\nob_start();\n\n// Include the HTML generation code here\n?>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Memento Mori</title>\n    <link rel=\"stylesheet\" href=\"style.css\">\n    <!-- Script to make post data available to JavaScript -->\n    <script>\n        window.postData = <?php echo json_encode($post_data); ?>;\n        \n        // Function to copy the current URL to clipboard\n        function copyCurrentUrl() {\n            const url = window.location.href;\n            navigator.clipboard.writeText(url)\n                .then(() => {\n                    alert('Link copied to clipboard!');\n                })\n                .catch(err => {\n                    console.error('Could not copy URL: ', err);\n                });\n        }\n    </script>\n    <?php\n    // Include the modal.js content directly in the output\n    echo '<script>';\n    echo file_get_contents('modal.js');\n    echo '</script>';\n    ?>\n  </head>\n  <body class=\"vsc-initialized\">\n    <header>\n      <div class=\"header-content\">\n        <a href=\"https://github.com/greg-randall/memento-mori\" class=\"logo\">Memento Mori</a>\n        <div class=\"date-range-header\" id=\"date-range-header\"><?php echo \"$first_timestamp - $last_timestamp\"; ?></div>\n      </div>\n    </header>\n    <main>\n      <div class=\"loading\" id=\"loadingPosts\" style=\"display: none;\"> Loading posts... </div>\n      <div class=\"profile-info\">\n        <div class=\"profile-picture\">\n          <img alt=\"Profile Picture\" src=\"<?php echo $profile_picture; ?>\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n        </div>\n        <div class=\"profile-details\">\n          <h1 id=\"username\"><?php echo $user_name; ?></h1>\n          <div class=\"stats\">\n            <div class=\"stat\">\n              <span class=\"stat-count\" id=\"post-count\"><?php echo count($post_data); ?></span> posts\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"sort-options\">\n        <div class=\"sort-row\">\n          <a href=\"#\" class=\"sort-link active\" data-sort=\"newest\">Newest</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"oldest\">Oldest</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-likes\">Most Likes</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-comments\">Most Comments</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-views\">Most Views</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"random\">Random</a>\n        </div>\n      </div>\n      <div class=\"posts-grid\" id=\"postsGrid\">\n        <?php echo render_instagram_grid($post_data); ?>\n        </div>\n       \n    </main>\n\n    <!-- Modal for post details -->\n    <div class=\"post-modal\" id=\"postModal\">\n        <div class=\"close-modal\" id=\"closeModal\">✕</div>\n        <div class=\"modal-nav modal-prev\" id=\"modalPrev\">❮</div>\n        <div class=\"modal-nav modal-next\" id=\"modalNext\">❯</div>\n        <div class=\"post-modal-content\">\n            <div class=\"post-media\" id=\"postMedia\"></div>\n            <div class=\"post-info\">\n                <div class=\"post-header\">\n                    <div class=\"post-user\" id=\"postUserPic\">\n                        <img src=\"<?php echo $profile_picture; ?>\" alt=\"Profile\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n                    </div>\n                    <div class=\"post-username\" id=\"postUsername\"><?php echo $user_name; ?></div>\n                </div>\n                <div class=\"post-caption\" id=\"postCaption\"></div>\n                <div class=\"post-stats\" id=\"postStats\"></div>\n                <div class=\"post-date\" id=\"postDate\"></div>\n            </div>\n        </div>\n    </div>\n  </body>\n</html>\n<?php\n// Get the HTML content\n$html_content = ob_get_contents();\nob_end_clean();\n\n// Write the HTML to the distribution folder\nfile_put_contents('distribution/index.html', $html_content);\n\n// Copy the CSS file to the distribution folder\nif (file_exists('style.css')) {\n    copy('style.css', 'distribution/style.css');\n    fwrite(STDERR, \"CSS file copied to distribution folder.\\n\");\n}\n\n// Verify all images in the HTML are accessible\nfwrite(STDERR, \"Verifying all images in the generated HTML...\\n\");\nverify_images_in_html($html_content);\n\n// Output the content to the browser as well\necho $html_content;\n\n/**\n * Verify that all images referenced in the HTML actually exist\n * \n * @param string $html_content The HTML content to check\n */\nfunction verify_images_in_html($html_content) {\n    // Extract all image sources from the HTML\n    preg_match_all('/<img[^>]+src=([\\'\"])([^\"\\']+)\\\\1/i', $html_content, $matches);\n    \n    $image_sources = $matches[2];\n    $total_images = count($image_sources);\n    $missing_images = 0;\n    $fixed_images = 0;\n    \n    fwrite(STDERR, \"Found $total_images image references to verify.\\n\");\n    \n    foreach ($image_sources as $src) {\n        // Skip data URIs\n        if (strpos($src, 'data:image') === 0) {\n            continue;\n        }\n        \n        // Check if the image exists in the distribution folder\n        $image_path = 'distribution/' . $src;\n        \n        if (!file_exists($image_path)) {\n            $missing_images++;\n            fwrite(STDERR, \"Missing image: $src\\n\");\n            \n            // Try to find the image with a different extension\n            $base_path = pathinfo($image_path, PATHINFO_DIRNAME) . '/' . pathinfo($image_path, PATHINFO_FILENAME);\n            $found = false;\n            \n            // Check common image extensions\n            foreach (['.jpg', '.jpeg', '.png', '.gif', '.webp'] as $ext) {\n                $alt_path = $base_path . $ext;\n                if (file_exists($alt_path)) {\n                    fwrite(STDERR, \"  Found alternative: \" . basename($alt_path) . \"\\n\");\n                    \n                    // Copy the file to the expected path\n                    copy($alt_path, $image_path);\n                    $fixed_images++;\n                    $found = true;\n                    break;\n                }\n            }\n            \n            if (!$found) {\n                // Check if the original file exists (before distribution)\n                $original_src = $src;\n                if (file_exists($original_src)) {\n                    fwrite(STDERR, \"  Found original file, copying to distribution: $original_src\\n\");\n                    \n                    // Create directory if it doesn't exist\n                    $dir = dirname($image_path);\n                    if (!file_exists($dir)) {\n                        mkdir($dir, 0755, true);\n                    }\n                    \n                    // Copy the file\n                    copy($original_src, $image_path);\n                    $fixed_images++;\n                }\n            }\n        }\n    }\n    \n    // Report results\n    if ($missing_images === 0) {\n        fwrite(STDERR, \"All images verified successfully!\\n\");\n    } else {\n        fwrite(STDERR, \"Found $missing_images missing images, fixed $fixed_images.\\n\");\n        if ($missing_images > $fixed_images) {\n            fwrite(STDERR, \"WARNING: \" . ($missing_images - $fixed_images) . \" images could not be fixed.\\n\");\n        }\n    }\n}\n?>\n\n"
  },
  {
    "path": "deprecated_php_utility/modal.js",
    "content": "document.addEventListener('DOMContentLoaded', function() {\n    // Get DOM elements\n    const postsGrid = document.getElementById('postsGrid');\n    const postModal = document.getElementById('postModal');\n    const closeModalBtn = document.getElementById('closeModal');\n    const modalPrev = document.getElementById('modalPrev');\n    const modalNext = document.getElementById('modalNext');\n    const postMedia = document.getElementById('postMedia');\n    const postCaption = document.getElementById('postCaption');\n    const postStats = document.getElementById('postStats');\n    const postDate = document.getElementById('postDate');\n    const postUsername = document.getElementById('postUsername');\n    const postUserPic = document.getElementById('postUserPic');\n    const sortLinks = document.querySelectorAll('.sort-link');\n    \n    // Global variables to track current post and indexes\n    let currentPostIndex = -1;\n    let currentSlideIndex = 0;\n    let postIndexToTimestamp = {}; // Map post index to timestamp\n    let currentSortType = 'newest'; // Default sort\n    \n    // Initialize by creating mapping and attaching listeners\n    function initialize() {\n        // Create a mapping from post_index to timestamp\n        Object.entries(window.postData).forEach(([timestamp, post]) => {\n            postIndexToTimestamp[post.post_index] = timestamp;\n        });\n        \n        // Attach click listeners to grid items\n        attachGridItemListeners();\n        \n        // Initialize sorting functionality\n        initializeSorting();\n    }\n    \n    // Initialize sorting functionality\n    function initializeSorting() {\n        // Add event listeners to sort links\n        sortLinks.forEach(link => {\n            link.addEventListener('click', function(e) {\n                e.preventDefault();\n                \n                // Update active class\n                sortLinks.forEach(l => l.classList.remove('active'));\n                this.classList.add('active');\n                \n                // Get sort type and sort posts\n                const sortType = this.getAttribute('data-sort');\n                currentSortType = sortType;\n                sortPosts(sortType);\n            });\n        });\n    }\n    \n    // Sort posts based on selected criteria\n    function sortPosts(sortType) {\n        // Get all grid items\n        let gridItems = Array.from(document.querySelectorAll('.grid-item'));\n        \n        // Sort the grid items based on the selected criteria\n        switch(sortType) {\n            case 'newest':\n                // Sort by timestamp (newest first) - this is the default\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const timestampA = getTimestampByIndex(indexA);\n                    const timestampB = getTimestampByIndex(indexB);\n                    return timestampB - timestampA;\n                });\n                break;\n                \n            case 'oldest':\n                // Sort by timestamp (oldest first)\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const timestampA = getTimestampByIndex(indexA);\n                    const timestampB = getTimestampByIndex(indexB);\n                    return timestampA - timestampB;\n                });\n                break;\n                \n            case 'most-likes':\n                // Sort by number of likes\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const likesA = getLikesByIndex(indexA) || 0;\n                    const likesB = getLikesByIndex(indexB) || 0;\n                    return likesB - likesA;\n                });\n                break;\n                \n            case 'most-comments':\n                // Sort by number of comments\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const commentsA = getCommentsByIndex(indexA) || 0;\n                    const commentsB = getCommentsByIndex(indexB) || 0;\n                    return commentsB - commentsA;\n                });\n                break;\n                \n            case 'most-views':\n                // Sort by number of views/impressions\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const viewsA = getViewsByIndex(indexA) || 0;\n                    const viewsB = getViewsByIndex(indexB) || 0;\n                    return viewsB - viewsA;\n                });\n                break;\n                \n            case 'random':\n                // Shuffle the grid items randomly\n                gridItems.sort(() => Math.random() - 0.5);\n                break;\n        }\n        \n        // Reorder the grid items in the DOM\n        const fragment = document.createDocumentFragment();\n        gridItems.forEach(item => {\n            fragment.appendChild(item);\n        });\n        \n        // Clear the grid and append the sorted items\n        postsGrid.innerHTML = '';\n        postsGrid.appendChild(fragment);\n        \n        // Reattach event listeners to grid items\n        attachGridItemListeners();\n    }\n    \n    // Helper function to get timestamp by post index\n    function getTimestampByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        return parseInt(timestamp);\n    }\n    \n    // Helper function to get likes by post index\n    function getLikesByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        if (timestamp && window.postData[timestamp]) {\n            return parseInt(window.postData[timestamp].Likes) || 0;\n        }\n        return 0;\n    }\n    \n    // Helper function to get comments by post index\n    function getCommentsByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        if (timestamp && window.postData[timestamp]) {\n            return parseInt(window.postData[timestamp].Comments) || 0;\n        }\n        return 0;\n    }\n    \n    // Helper function to get views/impressions by post index\n    function getViewsByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        if (timestamp && window.postData[timestamp]) {\n            return parseInt(window.postData[timestamp].Impressions) || 0;\n        }\n        return 0;\n    }\n    \n    // Attach click event listeners to all grid items\n    function attachGridItemListeners() {\n        const gridItems = document.querySelectorAll('.grid-item');\n        gridItems.forEach(item => {\n            item.addEventListener('click', function() {\n                const postIndex = parseInt(this.getAttribute('data-index'));\n                openModal(postIndex);\n            });\n        });\n    }\n    \n    // Open the modal with the selected post\nfunction openModal(index, imageIndex = 0) {\n    currentPostIndex = index;\n    \n    // Store the current scroll position before opening the modal\n    const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;\n    \n    // Get the timestamp using the post_index mapping\n    const timestamp = postIndexToTimestamp[index];\n    \n    // Get the post data using the timestamp\n    const post = window.postData[timestamp];\n    \n    // Show the modal first (important for correct dimensions)\n    postModal.style.display = 'block';\n    document.body.style.overflow = 'hidden'; // Prevent scrolling\n    \n    // Store the scroll position as a data attribute on the modal\n    postModal.setAttribute('data-scroll-position', scrollPosition);\n    \n    // Update modal content\n    updateModalContent(post, imageIndex);\n    \n    // Update URL with post ID and image index\n    updateUrlWithPostInfo(timestamp, imageIndex);\n    \n    // For mobile devices, ensure content is visible and properly sized\n    if (window.innerWidth <= 768) {\n        // Don't scroll to top on mobile as it causes the issue\n        // Instead, just ensure the modal is properly positioned\n        postModal.scrollTop = 0;\n        \n        // Force layout recalculation with a longer timeout\n        setTimeout(() => {\n            const mediaContainer = document.querySelector('.media-container');\n            const postMediaEl = document.getElementById('postMedia');\n            \n            // Ensure post-media has explicit height\n            if (postMediaEl) {\n                postMediaEl.style.height = '50vh';\n                postMediaEl.style.minHeight = '300px';\n            }\n            \n            // Ensure media-container has explicit height\n            if (mediaContainer) {\n                mediaContainer.style.height = '100%';\n                mediaContainer.style.display = 'flex';\n                \n                // Force reflow\n                void mediaContainer.offsetHeight;\n            }\n            \n            // Reset any active slides to ensure they're visible\n            const activeSlides = document.querySelectorAll('.media-slide.active');\n            activeSlides.forEach(slide => {\n                slide.style.opacity = '0';\n                void slide.offsetHeight; // Force reflow\n                slide.style.opacity = '1';\n                \n                // Make sure images have height\n                const img = slide.querySelector('img');\n                if (img) {\n                    img.style.maxHeight = '100%';\n                    img.style.width = 'auto';\n                    img.style.height = 'auto';\n                }\n            });\n        }, 50); // Increase timeout for more reliability\n    }\n}\n\n// Function to update the URL with post and image information\nfunction updateUrlWithPostInfo(timestamp, imageIndex) {\n    // Create a new URL object based on the current URL\n    const url = new URL(window.location.href);\n    \n    // Set the post parameter to the timestamp\n    url.searchParams.set('post', timestamp);\n    \n    // Only add the image parameter if it's not the first image\n    if (imageIndex > 0) {\n        url.searchParams.set('image', imageIndex);\n    } else {\n        url.searchParams.delete('image');\n    }\n    \n    // Update the browser history without reloading the page\n    window.history.pushState({}, '', url);\n}\n    //Creates the appropriate media element (video or image) based on the file type\n    function createMediaElement(mediaUrl) {\n        // Check if the media is a video based on file extension\n        if (mediaUrl.endsWith('.mp4') || mediaUrl.endsWith('.mov') || \n            mediaUrl.endsWith('.avi') || mediaUrl.endsWith('.webm')) {\n            \n            // Create video element\n            const video = document.createElement('video');\n            video.src = mediaUrl;\n            video.controls = true;\n            video.autoplay = true;\n            video.loop = true;\n            video.muted = false;\n            video.playsInline = true;\n            video.alt = 'Instagram video post';\n            \n            return video;\n        } else {\n            // Create image element\n            const img = document.createElement('img');\n            \n            // Check if there's a WebP version available for non-WebP images\n            if (!mediaUrl.endsWith('.webp') && \n                (mediaUrl.endsWith('.jpg') || mediaUrl.endsWith('.jpeg') || \n                 mediaUrl.endsWith('.png') || mediaUrl.endsWith('.gif'))) {\n                \n                // Try to use WebP version if it exists\n                const webpUrl = mediaUrl.replace(/\\.(jpg|jpeg|png|gif)$/i, '.webp');\n                \n                // Set up error handling to fall back to original if WebP doesn't exist\n                img.onerror = function() {\n                    this.onerror = null; // Prevent infinite loop\n                    this.src = mediaUrl; // Fall back to original\n                };\n                \n                img.src = webpUrl;\n            } else {\n                img.src = mediaUrl;\n            }\n            \n            img.alt = 'Instagram post';\n            \n            return img;\n        }\n    }\n    // Update the modal content with the post data\n    function updateModalContent(post, initialImageIndex = 0) {\n        // Clear previous content\n        postMedia.innerHTML = '';\n        postCaption.innerHTML = '';\n        postStats.innerHTML = '';\n        \n        // Create media container for the slides\n        const mediaContainer = document.createElement('div');\n        mediaContainer.className = 'media-container';\n        \n        // Check if the post has multiple media\n        if (post.media && post.media.length > 1) {\n            // Create slides for each media item\n            post.media.forEach((mediaUrl, index) => {\n                const slide = document.createElement('div');\n                slide.className = `media-slide ${index === initialImageIndex ? 'active' : ''}`;\n                \n                // Create and add the appropriate media element\n                const mediaElement = createMediaElement(mediaUrl);\n                slide.appendChild(mediaElement);\n                \n                mediaContainer.appendChild(slide);\n            });\n            \n            // Add navigation buttons for slideshow\n            const prevBtn = document.createElement('div');\n            prevBtn.className = 'slideshow-nav slideshow-prev';\n            prevBtn.innerHTML = '❮';\n            prevBtn.addEventListener('click', function(e) {\n                e.stopPropagation();\n                navigateSlideshow(-1);\n            });\n            \n            const nextBtn = document.createElement('div');\n            nextBtn.className = 'slideshow-nav slideshow-next';\n            nextBtn.innerHTML = '❯';\n            nextBtn.addEventListener('click', function(e) {\n                e.stopPropagation();\n                navigateSlideshow(1);\n            });\n            \n            // Add indicator dots\n            const indicator = document.createElement('div');\n            indicator.className = 'slideshow-indicator';\n            \n            for (let i = 0; i < post.media.length; i++) {\n                const dot = document.createElement('div');\n                dot.className = `slideshow-dot ${i === initialImageIndex ? 'active' : ''}`;\n                dot.setAttribute('data-index', i);\n                dot.addEventListener('click', function(e) {\n                    e.stopPropagation();\n                    const index = parseInt(this.getAttribute('data-index'));\n                    showSlide(index);\n                });\n                indicator.appendChild(dot);\n            }\n            \n            mediaContainer.appendChild(prevBtn);\n            mediaContainer.appendChild(nextBtn);\n            mediaContainer.appendChild(indicator);\n            \n            // Set the current slide index to the initial image index\n            currentSlideIndex = initialImageIndex;\n        } else {\n            // Single media post\n            const slide = document.createElement('div');\n            slide.className = 'media-slide active';\n            \n            // Create and add the appropriate media element\n            const mediaElement = createMediaElement(post.media[0]);\n            slide.appendChild(mediaElement);\n            \n            mediaContainer.appendChild(slide);\n        }\n        \n        postMedia.appendChild(mediaContainer);\n        \n        // Set post caption\n        postCaption.textContent = post.title || '';\n        \n        // Set post stats\n        if (post.Impressions) {\n            const impressionsDiv = document.createElement('div');\n            impressionsDiv.className = 'post-stat';\n            impressionsDiv.innerHTML = `\n                <span class=\"post-stat-icon\">👁️</span>\n                <span>${post.Impressions} views</span>\n            `;\n            postStats.appendChild(impressionsDiv);\n        }\n        \n        if (post.Likes) {\n            const likesDiv = document.createElement('div');\n            likesDiv.className = 'post-stat';\n            likesDiv.innerHTML = `\n                <span class=\"post-stat-icon\">♥</span>\n                <span>${post.Likes}</span>\n            `;\n            postStats.appendChild(likesDiv);\n        }\n        \n        if (post.Comments) {\n            const commentsDiv = document.createElement('div');\n            commentsDiv.className = 'post-stat';\n            commentsDiv.innerHTML = `\n                <span class=\"post-stat-icon\">💬</span>\n                <span>${post.Comments} comments</span>\n            `;\n            postStats.appendChild(commentsDiv);\n        }\n        \n        // Set post date\n        postDate.textContent = post.creation_timestamp_readable;\n        \n        // Show/hide stats container based on whether there are any stats\n        postStats.style.display = postStats.children.length > 0 ? 'flex' : 'none';\n    }\n    \n    // Navigate between slides in a multi-media post\n    function navigateSlideshow(direction) {\n        const slides = document.querySelectorAll('.media-slide');\n        const dots = document.querySelectorAll('.slideshow-dot');\n        let activeIndex = 0;\n        \n        // Find the currently active slide\n        slides.forEach((slide, index) => {\n            if (slide.classList.contains('active')) {\n                activeIndex = index;\n            }\n        });\n        \n        // Pause any videos in the current slide\n        const currentVideo = slides[activeIndex].querySelector('video');\n        if (currentVideo) {\n            currentVideo.pause();\n        }\n        \n        // Calculate the new index\n        let newIndex = activeIndex + direction;\n        if (newIndex < 0) newIndex = slides.length - 1;\n        if (newIndex >= slides.length) newIndex = 0;\n        \n        // Update slides and dots\n        showSlide(newIndex);\n    }\n    \n    // Show a specific slide\n    function showSlide(index) {\n        const slides = document.querySelectorAll('.media-slide');\n        const dots = document.querySelectorAll('.slideshow-dot');\n        \n        // Pause all videos before changing slides\n        slides.forEach(slide => {\n            const video = slide.querySelector('video');\n            if (video) {\n                video.pause();\n            }\n        });\n        \n        // Remove active class from all slides and dots\n        slides.forEach(slide => slide.classList.remove('active'));\n        if (dots.length > 0) {\n            dots.forEach(dot => dot.classList.remove('active'));\n            dots[index].classList.add('active');\n        }\n        \n        // Add active class to the selected slide\n        slides[index].classList.add('active');\n        \n        // Update current slide index\n        currentSlideIndex = index;\n        \n        // Update URL with the new image index\n        const timestamp = postIndexToTimestamp[currentPostIndex];\n        updateUrlWithPostInfo(timestamp, index);\n    }\n    \n    // Navigate between posts (next/prev buttons in modal)\n    function navigatePost(direction) {\n        // Pause all videos in the current post\n        const videos = document.querySelectorAll('.media-slide video');\n        videos.forEach(video => {\n            if (video) {\n                video.pause();\n            }\n        });\n        \n        // Get all grid items in their current sorted order\n        const gridItems = Array.from(document.querySelectorAll('.grid-item'));\n        const gridIndexes = gridItems.map(item => parseInt(item.getAttribute('data-index')));\n        \n        // Find the position of the current post in the sorted grid\n        const currentPosition = gridIndexes.indexOf(currentPostIndex);\n        \n        if (currentPosition === -1) {\n            console.error('Current post not found in grid');\n            return;\n        }\n        \n        // Calculate new position with wraparound\n        let newPosition = (currentPosition + direction + gridIndexes.length) % gridIndexes.length;\n        \n        // Get the new post index from the grid's current order\n        const newPostIndex = gridIndexes[newPosition];\n        \n        // Open the new post\n        openModal(newPostIndex);\n    }\n    \n    // Close the modal\n    function closeModal() {\n        // Pause all videos before closing the modal\n        const videos = document.querySelectorAll('.media-slide video');\n        videos.forEach(video => {\n            if (video) {\n                video.pause();\n            }\n        });\n        \n        // Store the current scroll position before closing the modal\n        const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;\n        \n        postModal.style.display = 'none';\n        document.body.style.overflow = 'auto'; // Re-enable scrolling\n        \n        // Remove post and image parameters from URL\n        const url = new URL(window.location.href);\n        url.searchParams.delete('post');\n        url.searchParams.delete('image');\n        window.history.pushState({}, '', url);\n        \n        // Restore the scroll position after a short delay\n        setTimeout(() => {\n            window.scrollTo({\n                top: scrollPosition,\n                behavior: 'auto' // Use 'auto' instead of 'smooth' to prevent visible scrolling\n            });\n        }, 10);\n    }\n    \n    // Event listeners for modal navigation\n    closeModalBtn.addEventListener('click', closeModal);\n    modalPrev.addEventListener('click', function(e) {\n        e.stopPropagation();\n        navigatePost(-1);\n    });\n    modalNext.addEventListener('click', function(e) {\n        e.stopPropagation();\n        navigatePost(1);\n    });\n    \n    // Close modal when clicking outside of content\n    postModal.addEventListener('click', function(e) {\n        if (e.target === postModal) {\n            closeModal();\n        }\n    });\n    \n    // Keyboard navigation\n    document.addEventListener('keydown', function(e) {\n        if (postModal.style.display === 'block') {\n            if (e.key === 'Escape') {\n                closeModal();\n            } else if (e.key === 'ArrowLeft') {\n                navigatePost(-1);\n            } else if (e.key === 'ArrowRight') {\n                navigatePost(1);\n            }\n        }\n    });\n    \n    // Initialize the modal functionality\n    if (typeof window.postData !== 'undefined') {\n        initialize();\n        \n        // Check if URL has post and image parameters\n        const urlParams = new URLSearchParams(window.location.search);\n        const postTimestamp = urlParams.get('post');\n        const imageIndex = parseInt(urlParams.get('image') || '0');\n        \n        if (postTimestamp && window.postData[postTimestamp]) {\n            // Find the post index from the timestamp\n            let postIndex = -1;\n            Object.entries(postIndexToTimestamp).forEach(([index, timestamp]) => {\n                if (timestamp === postTimestamp) {\n                    postIndex = parseInt(index);\n                }\n            });\n            \n            if (postIndex >= 0) {\n                // Open the modal with the specified post and image\n                setTimeout(() => {\n                    openModal(postIndex, imageIndex);\n                }, 500); // Delay to ensure everything is loaded\n            }\n        }\n    } else {\n        console.error('Post data not available');\n    }\n});\n"
  },
  {
    "path": "deprecated_php_utility/notes.md",
    "content": "The PHP version may be easier to run on shared hosting environments and doesn't require additional packages if PHP is already installed with the necessary extensions.\n\n## Troubleshooting\n\n- If you see errors about GD library or WebP support, you may need to install additional PHP extensions\n- For video thumbnail generation, ensure FFmpeg is installed and accessible in your system path\n- For HEIC file support, ensure ImageMagick is installed\n- If using Docker, ensure you have permissions to write to the output directory\n- For large archives, be patient as processing media files can take time\n"
  },
  {
    "path": "deprecated_php_utility/style.css",
    "content": ":root {\n  --instagram-bg: #fafafa;\n  --instagram-border: #dbdbdb;\n  --instagram-text: #262626;\n  --instagram-link: #0095f6;\n  --header-height: 60px;\n}\n\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nbody {\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n  background-color: var(--instagram-bg);\n  color: var(--instagram-text);\n  line-height: 1.5;\n}\n\nheader {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: var(--header-height);\n  background-color: white;\n  border-bottom: 1px solid var(--instagram-border);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0 20px;\n  z-index: 100;\n}\n\n.header-content {\n  max-width: 975px;\n  width: 100%;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.logo {\n  font-size: 24px;\n  font-weight: bold;\n  color: var(--instagram-text);\n  text-decoration: none;\n}\n\n.date-range-header {\n  color: #8e8e8e;\n  font-size: 14px;\n  margin-left: 15px;\n}\n\nmain {\n  max-width: 975px;\n  margin: calc(var(--header-height) + 30px) auto 30px;\n  padding: 0 20px;\n}\n\n.profile-info {\n  display: flex;\n  align-items: center;\n  margin-bottom: 30px;\n}\n\n.profile-picture {\n  width: 150px;\n  height: 150px;\n  border-radius: 50%;\n  object-fit: cover;\n  margin-right: 30px;\n  background-color: #eee;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 36px;\n  color: #aaa;\n}\n\n.profile-details h1 {\n  font-size: 28px;\n  font-weight: 300;\n  margin-bottom: 15px;\n}\n\n.stats {\n  display: flex;\n  margin-bottom: 15px;\n  font-size: 16px;\n}\n\n.stat {\n  margin-right: 40px;\n}\n\n.stat-count {\n  font-weight: 600;\n}\n\n.posts-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 28px;\n}\n\n.grid-item {\n  position: relative;\n  aspect-ratio: 1/1;\n  cursor: pointer;\n  overflow: hidden;\n}\n\n.grid-item img,\n.grid-item video {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  transition: transform 0.3s ease;\n  aspect-ratio: 1/1;\n}\n\n.grid-item:hover img,\n.grid-item:hover video {\n  transform: scale(1.05);\n}\n\n.multi-indicator {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  color: white;\n  background-color: rgba(0, 0, 0, 0.6);\n  padding: 3px 8px;\n  border-radius: 4px;\n  font-size: 12px;\n  z-index: 2;\n}\n\n.video-indicator {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  color: white;\n  background-color: rgba(0, 0, 0, 0.7);\n  padding: 4px 10px;\n  border-radius: 4px;\n  font-size: 12px;\n  font-weight: bold;\n  z-index: 2;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n}\n\n.post-modal {\n  display: none;\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(0, 0, 0, 0.9);\n  z-index: 1000;\n  overflow-y: auto;\n}\n\n.post-modal-content {\n  display: flex;\n  max-width: 1200px;\n  margin: 30px auto;\n  background-color: white;\n  height: calc(100vh - 60px);\n  max-height: 800px;\n  border-radius: 4px;\n  overflow: hidden;\n  position: relative;\n}\n\n.post-media {\n  flex: 1;\n  background-color: black;\n  position: relative;\n  min-width: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.post-media img,\n.post-media video {\n  max-width: 100%;\n  max-height: 100%;\n  object-fit: contain;\n}\n\n.post-info {\n  width: 340px;\n  border-left: 1px solid var(--instagram-border);\n  display: flex;\n  flex-direction: column;\n}\n\n.post-header {\n  padding: 16px;\n  border-bottom: 1px solid var(--instagram-border);\n  display: flex;\n  align-items: center;\n}\n\n.post-user {\n  width: 32px;\n  height: 32px;\n  border-radius: 50%;\n  margin-right: 12px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background-color: #eee;\n  font-size: 14px;\n  color: #aaa;\n}\n\n.post-username {\n  font-weight: 600;\n  flex-grow: 1;\n}\n\n.share-button {\n  cursor: pointer;\n  padding: 5px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: background-color 0.2s;\n}\n\n.share-button:hover {\n  background-color: rgba(0, 0, 0, 0.1);\n}\n\n.share-button svg {\n  width: 18px;\n  height: 18px;\n  color: #8e8e8e;\n}\n\n.post-caption {\n  padding: 16px;\n  flex-grow: 1;\n  overflow-y: auto;\n}\n\n.post-date {\n  padding: 16px;\n  color: #8e8e8e;\n  font-size: 12px;\n  border-top: 1px solid var(--instagram-border);\n}\n\n.post-stats {\n  padding: 12px 16px;\n  color: var(--instagram-text);\n  font-size: 14px;\n  border-top: 1px solid var(--instagram-border);\n  display: flex;\n  gap: 16px;\n}\n\n.post-stat {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.post-stat-icon {\n  font-size: 16px;\n}\n\n.likes-indicator {\n  position: absolute;\n  bottom: 10px;\n  left: 10px;\n  color: white;\n  background-color: rgba(0, 0, 0, 0.7);\n  padding: 4px 10px;\n  border-radius: 4px;\n  font-size: 12px;\n  font-weight: bold;\n  z-index: 2;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n}\n\n.close-modal {\n  position: absolute;\n  top: 20px;\n  right: 20px;\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 1001;\n}\n\n.modal-nav {\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 1001;\n  background-color: rgba(0, 0, 0, 0.5);\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.modal-prev {\n  left: 20px;\n}\n\n.modal-next {\n  right: 20px;\n}\n\n.slideshow-nav {\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 5;\n  background-color: rgba(0, 0, 0, 0.7);\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: background-color 0.2s;\n}\n\n.slideshow-nav:hover {\n  background-color: rgba(0, 0, 0, 0.9);\n}\n\n.slideshow-prev {\n  left: 10px;\n}\n\n.slideshow-next {\n  right: 10px;\n}\n\n.slideshow-indicator {\n  position: absolute;\n  bottom: 20px;\n  left: 0;\n  right: 0;\n  display: flex;\n  justify-content: center;\n  z-index: 5;\n  background-color: rgba(0, 0, 0, 0.3);\n  padding: 8px 0;\n  border-radius: 20px;\n  width: auto;\n  max-width: 80%;\n  margin: 0 auto;\n}\n\n.slideshow-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background-color: rgba(255, 255, 255, 0.5);\n  margin: 0 4px;\n  cursor: pointer;\n  transition: background-color 0.2s;\n}\n\n.slideshow-dot:hover {\n  background-color: rgba(255, 255, 255, 0.8);\n}\n\n.slideshow-dot.active {\n  background-color: white;\n}\n\n.media-container {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.media-slide {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  opacity: 0;\n  transition: opacity 0.3s ease;\n  pointer-events: none;\n}\n\n.media-slide img,\n.media-slide video {\n  max-width: 100%;\n  max-height: 100%;\n  object-fit: contain;\n}\n\n.media-slide.active {\n  opacity: 1;\n  z-index: 2;\n  pointer-events: auto;\n}\n\n.media-slide.active {\n  opacity: 1;\n  z-index: 2;\n}\n\n.file-input-container {\n  margin-bottom: 20px;\n  padding: 20px;\n  background-color: white;\n  border: 1px solid var(--instagram-border);\n  border-radius: 4px;\n}\n\n.loading {\n  text-align: center;\n  padding: 40px;\n  font-size: 18px;\n}\n\n.sort-options {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 10px 20px;\n  margin-bottom: 20px;\n}\n\n.sort-row {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-wrap: wrap;\n  margin: 5px 0;\n  width: 100%;\n  max-width: 600px;\n}\n\n.sort-link {\n  margin: 0 10px;\n  color: var(--instagram-text);\n  text-decoration: none;\n  padding: 5px 0;\n  position: relative;\n  transition: color 0.2s;\n}\n\n.sort-link:hover {\n  color: var(--instagram-link);\n}\n\n.sort-link.active {\n  color: var(--instagram-link);\n  font-weight: 600;\n}\n\n.sort-link.active::after {\n  content: '';\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  width: 100%;\n  height: 2px;\n  background-color: var(--instagram-link);\n}\n\n@media (max-width: 768px) {\n  .posts-grid {\n    grid-template-columns: repeat(2, 1fr);\n    gap: 4px;\n  }\n\n  .post-modal-content {\n    flex-direction: column;\n    height: auto;\n    max-height: none;\n    margin: 30px auto 0;\n    border-radius: 0;\n    width: 100%;\n  }\n\n  .post-media {\n    height: 50vh;\n    width: 100%;\n    min-height: 300px;\n    position: relative;\n  }\n\n  .post-info {\n    width: 100%;\n    border-left: none;\n    border-top: 1px solid var(--instagram-border);\n  }\n\n  .profile-picture {\n    width: 80px;\n    height: 80px;\n    margin-right: 15px;\n  }\n\n  .stat {\n    margin-right: 20px;\n  }\n  \n  .post-modal {\n    overflow-y: auto;\n    padding-top: 0;\n  }\n  \n  .media-container {\n    position: relative;\n    width: 100%;\n    height: 100%;\n  }\n  \n  .media-slide {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n  \n  .media-slide img,\n  .media-slide video {\n    max-width: 100%;\n    max-height: 100%;\n    object-fit: contain;\n  }\n}\n\n@media (max-width: 480px) {\n  .posts-grid {\n    grid-template-columns: repeat(3, 1fr);\n    gap: 3px;\n  }\n\n  .profile-info {\n    flex-direction: column;\n    text-align: center;\n  }\n\n  .profile-picture {\n    margin-right: 0;\n    margin-bottom: 15px;\n  }\n\n  .stats {\n    justify-content: center;\n  }\n  \n  .header-content {\n    flex-direction: column;\n    align-items: center;\n    padding: 5px 0;\n  }\n  \n  .date-range-header {\n    margin-left: 0;\n    margin-top: 2px;\n    font-size: 12px;\n  }\n  \n  .sort-options {\n    padding: 5px;\n  }\n  \n  .sort-row {\n    width: 100%;\n    flex-wrap: wrap;\n    justify-content: center;\n  }\n  \n  .sort-link {\n    margin: 5px;\n    font-size: 13px;\n    padding: 5px 0;\n    flex: 0 0 auto;\n  }\n}\n\n/* Mobile-specific fixes */\n@media (max-width: 768px) {\n  /* Ensure the modal takes up the full screen */\n  .post-modal {\n    padding: 0;\n    overflow-y: auto;\n  }\n  \n  /* Make modal content take full width */\n  .post-modal-content {\n    flex-direction: column;\n    height: auto;\n    margin: 0;\n    width: 100%;\n    max-width: 100%;\n  }\n  \n  /* Explicitly set post-media height */\n  .post-media {\n    height: 50vh !important; /* Important to override any inline styles */\n    min-height: 300px !important;\n    width: 100%;\n    flex: 0 0 auto; /* Don't grow or shrink */\n  }\n  \n  /* Ensure media container fills the available space */\n  .media-container {\n    position: relative;\n    width: 100%;\n    height: 100% !important;\n    display: flex !important;\n    align-items: center;\n    justify-content: center;\n  }\n  \n  /* Fix media slides */\n  .media-slide {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    display: flex !important;\n    align-items: center;\n    justify-content: center;\n  }\n  \n  /* Ensure images don't exceed container */\n  .media-slide img,\n  .media-slide video {\n    max-width: 100%;\n    max-height: 100%;\n    width: auto;\n    height: auto;\n    object-fit: contain;\n  }\n  \n  /* Make post info section scroll independently if needed */\n  .post-info {\n    flex: 1 1 auto;\n    overflow-y: auto;\n    max-height: 50vh;\n  }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  memento-mori:\n    build: .\n    volumes:\n      - ./:/app/workspace\n      - ./output:/output\n    environment:\n      - PYTHONUNBUFFERED=1\n    command: --search-dir /app/workspace --output /output"
  },
  {
    "path": "memento_mori/__init__.py",
    "content": "# __init__.py\n\"\"\"\nMemento Mori - Instagram Archive Viewer\n\nA tool that converts your Instagram data export into a beautiful, standalone viewer that\nresembles the Instagram interface. The name \"Memento Mori\" (Latin for \"remember that\nyou will die\") reflects the ephemeral nature of our digital content.\n\"\"\"\n\n__version__ = \"0.1.0\"\n\n# Import main classes for easier access\nfrom .extractor import InstagramArchiveExtractor\nfrom .file_mapper import InstagramFileMapper\nfrom .loader import InstagramDataLoader\nfrom .media import InstagramMediaProcessor\nfrom .generator import InstagramSiteGenerator\n\n# Define what's available when using `from memento_mori import *`\n__all__ = [\n    \"InstagramArchiveExtractor\",\n    \"InstagramFileMapper\",\n    \"InstagramDataLoader\",\n    \"InstagramMediaProcessor\",\n    \"InstagramSiteGenerator\",\n]\n"
  },
  {
    "path": "memento_mori/cli.py",
    "content": "# memento_mori/cli.py\n\nimport os\nimport argparse\nimport multiprocessing\nfrom pathlib import Path\nimport traceback\nimport sys\n\nfrom memento_mori.extractor import InstagramArchiveExtractor\nfrom memento_mori.loader import InstagramDataLoader\nfrom memento_mori.media import InstagramMediaProcessor\nfrom memento_mori.generator import InstagramSiteGenerator\n\n\ndef main():\n    \"\"\"Main entry point for the Memento Mori CLI.\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"Transform Instagram data export into a viewer.\"\n    )\n\n    parser.add_argument(\n        \"--input\",\n        type=str,\n        help=\"Path to Instagram data (ZIP or folder). If not specified, auto-detection will be used.\",\n    )\n    parser.add_argument(\n        \"--output\",\n        type=str,\n        default=\"./output\",\n        help=\"Output directory for generated website [default: ./output]\",\n    )\n    parser.add_argument(\n        \"--threads\",\n        type=int,\n        default=0,\n        help=\"Number of parallel processing threads [default: auto]\",\n    )\n    parser.add_argument(\n        \"--search-dir\",\n        type=str,\n        default=\".\",\n        help=\"Directory to search for Instagram exports when auto-detecting [default: current directory]\",\n    )\n    parser.add_argument(\n        \"--quality\",\n        type=int,\n        default=70,\n        help=\"WebP conversion quality (1-100) [default: 70]\",\n    )\n    parser.add_argument(\n        \"--max-dimension\",\n        type=int,\n        default=1920,\n        help=\"Maximum dimension for images in pixels [default: 1920]\",\n    )\n    parser.add_argument(\n        \"--thumbnail-size\",\n        type=str,\n        default=\"292x292\",\n        help=\"Size of thumbnails [default: 292x292]\",\n    )\n    parser.add_argument(\n        \"--no-auto-detect\",\n        action=\"store_true\",\n        help=\"Disable auto-detection (requires --input to be specified)\",\n    )\n    parser.add_argument(\n        \"--gtag-id\",\n        type=str,\n        help=\"Google Analytics tag ID (e.g., 'G-DX1ZWTC9NZ') to add tracking to the generated site\",\n    )\n    parser.add_argument(\n        \"--verbose\", \"-v\",\n        action=\"store_true\",\n        help=\"Enable verbose output for debugging\",\n    )\n\n    args = parser.parse_args()\n\n    # Set defaults for threads if not specified\n    if args.threads <= 0:\n        args.threads = max(1, multiprocessing.cpu_count() - 1)\n\n    # Parse thumbnail size\n    try:\n        if \"x\" in args.thumbnail_size:\n            width, height = map(int, args.thumbnail_size.lower().split(\"x\"))\n            thumbnail_size = (width, height)\n        else:\n            size = int(args.thumbnail_size)\n            thumbnail_size = (size, size)\n    except ValueError:\n        print(f\"Invalid thumbnail size: {args.thumbnail_size}, using default 292x292\")\n        thumbnail_size = (292, 292)\n\n    # Create output directory\n    output_dir = Path(args.output)\n    output_dir.mkdir(parents=True, exist_ok=True)\n\n    # Initialize extractor with input path if specified\n    extractor = InstagramArchiveExtractor(input_path=args.input)\n\n    # Handle input selection\n    # If input is explicitly provided, use that\n    if args.input:\n        print(f\"Using specified input: {args.input}\")\n    # If auto-detect is not disabled, try to find an export\n    elif not args.no_auto_detect:\n        print(f\"Auto-detecting Instagram archive in {args.search_dir}...\")\n        detected_archive = extractor.auto_detect_archive(search_dir=args.search_dir)\n        if not detected_archive:\n            print(\n                \"No Instagram archive detected. Please specify an input file with --input.\"\n            )\n            return 1\n        print(f\"Detected archive: {detected_archive}\")\n    # If no input and auto-detect disabled, raise error\n    else:\n        print(\"Error: No input specified and auto-detection is disabled.\")\n        print(\"Please provide an input path with --input.\")\n        return 1\n\n    try:\n        # Extract archive\n        print(\"\\n📦 EXTRACTING ARCHIVE\")\n        print(f\"   Source: {extractor.input_path}\")\n        extraction_dir = extractor.extract()\n        print(f\"   Extracted to: {extraction_dir}\")\n\n        # Get file mapper from extractor\n        file_mapper = extractor.file_mapper\n\n        # Initialize loader with the same file mapper\n        print(\"\\n📋 LOADING DATA\")\n        loader = InstagramDataLoader(extraction_dir, file_mapper, verbose=args.verbose)\n\n        # Load and process data\n        data = loader.load_all_data()\n        \n        if args.verbose:\n            print(\"\\n🔍 VERBOSE: Data Loading Details\")\n            print(f\"   Profile data found: {'Yes' if loader.profile_data else 'No'}\")\n            print(f\"   Location data found: {'Yes' if loader.location_data else 'No'}\")\n            print(f\"   Posts data found: {'Yes' if loader.posts_data else 'No'}\")\n            print(f\"   Insights data found: {'Yes' if loader.insights_data else 'No'}\")\n            print(f\"   Combined data entries: {len(loader.combined_data) if loader.combined_data else 0}\")\n            \n            # Show file paths that were found\n            print(\"\\n   File paths found:\")\n            for file_type, file_path in file_mapper.file_map.items():\n                if isinstance(file_path, list):\n                    print(f\"      {file_type}: {len(file_path)} files\")\n                    if args.verbose:\n                        for i, path in enumerate(file_path[:3]):  # Show first 3 only\n                            print(f\"         - {path}\")\n                        if len(file_path) > 3:\n                            print(f\"         - ... and {len(file_path)-3} more\")\n                else:\n                    print(f\"      {file_type}: {file_path}\")\n        \n        print(f\"   Found {data['post_count']} posts from {data['profile']['username']}\")\n\n        # Process media files\n        print(f\"\\n🖼️  PROCESSING MEDIA\")\n        print(f\"   Using {args.threads} threads, quality {args.quality}, max dimension {args.max_dimension}...\")\n        media_processor = InstagramMediaProcessor(\n            extraction_dir, output_dir, thread_count=args.threads,\n            quality=args.quality, max_dimension=args.max_dimension\n        )\n        media_result = media_processor.process_media_files(\n            data[\"posts\"], data[\"profile\"][\"profile_picture\"], data.get(\"stories\", {})\n        )\n\n        # Update data with shortened filenames\n        data[\"posts\"] = media_result[\"updated_post_data\"]\n        data[\"profile\"][\"profile_picture\"] = media_result[\"shortened_profile\"]\n        \n        # Update stories data if it exists\n        if \"stories\" in data and media_result.get(\"updated_stories_data\"):\n            data[\"stories\"] = media_result[\"updated_stories_data\"]\n\n        # Generate website with the loaded data\n        print(\"\\n🌐 GENERATING WEBSITE\")\n        generator = InstagramSiteGenerator(data, output_dir, gtag_id=args.gtag_id)\n        success = generator.generate()\n\n        if success:\n            stats = media_result[\"stats\"]\n            print(\"\\n✅ PROCESS COMPLETE\")\n            print(f\"   Website generated at: {output_dir}\")\n            print(f\"   Posts processed: {data['post_count']}\")\n            print(f\"   Media files processed: {stats['thumbnail_count'] + stats['webp_count']}\")\n            print(f\"   Space saved: {stats['space_saved_mb']:.2f} MB ({stats['percentage_saved']:.1f}%)\")\n            print(f\"   Fixed file extensions: {stats['extension_fixes']}\")\n            return 0\n        else:\n            print(\"\\n❌ ERROR: Failed to generate website.\")\n            return 1\n\n    except Exception as e:\n        print(f\"\\n❌ ERROR: {str(e)}\")\n        if args.verbose:\n            print(\"\\n🔍 VERBOSE: Exception traceback\")\n            traceback.print_exc(file=sys.stdout)\n        return 1\n\n\nif __name__ == \"__main__\":\n    exit(main())\n"
  },
  {
    "path": "memento_mori/extractor.py",
    "content": "# memento_mori/extractor.py\nimport os\nimport zipfile\nimport tempfile\nimport shutil\nfrom pathlib import Path\nfrom .file_mapper import InstagramFileMapper\n\n\nclass InstagramArchiveExtractor:\n    \"\"\"\n    Class for handling the extraction and validation of Instagram data archives.\n\n    This class provides methods to:\n    - Auto-detect Instagram archive files\n    - Extract archives to temporary or specified locations\n    - Validate the structure of extracted content\n    - Clean up temporary files after processing\n    \"\"\"\n\n    REQUIRED_FILES = [\"profile\", \"posts\"]\n\n    def __init__(self, input_path=None, output_path=None, cleanup=True):\n        \"\"\"\n        Initialize the extractor with paths and options.\n\n        Args:\n            input_path (str, optional): Path to the Instagram archive (ZIP or folder)\n            output_path (str, optional): Path where extracted content should be placed\n            cleanup (bool): Whether to clean up temporary files after extraction\n        \"\"\"\n        self.input_path = input_path\n        self.input_paths = [input_path] if input_path else []\n        self.output_path = output_path\n        self.cleanup = cleanup\n        self.temp_dir = None\n        self.extraction_dir = None\n        self.file_mapper = None\n        self.file_map = {}  # Maps required file types to their actual paths\n\n    def auto_detect_archive(self, search_dir=\".\"):\n        \"\"\"\n        Auto-detect Instagram archive files in the specified directory.\n\n        Args:\n            search_dir (str): Directory to search for Instagram archives\n\n        Returns:\n            str: Path to the detected archive or None if not found\n        \"\"\"\n        print(f\"🔍 DETECTING INSTAGRAM ARCHIVE\")\n        print(f\"   Searching in: {search_dir}\")\n        \n        # Look for ZIP files that might be Instagram archives\n        potential_archives = []\n\n        for root, _, files in os.walk(search_dir):\n            for file in files:\n                if file.lower().endswith(\".zip\"):\n                    zip_path = os.path.join(root, file)\n                    # Check if this ZIP might be an Instagram archive\n                    if self._is_instagram_archive(zip_path):\n                        potential_archives.append(zip_path)\n\n        if not potential_archives:\n            print(\"   No Instagram archives found.\")\n            return None\n\n        # Sort by modification time (oldest first, so newest archive is extracted last and wins on conflicts)\n        potential_archives.sort(key=lambda x: os.path.getmtime(x))\n\n        if len(potential_archives) > 1:\n            print(f\"   Found {len(potential_archives)} archives. All will be merged.\")\n            for archive in potential_archives:\n                print(f\"   - {os.path.basename(archive)}\")\n\n        self.input_path = potential_archives[0]\n        self.input_paths = potential_archives\n        print(f\"   Selected: {os.path.basename(self.input_path)}\")\n        return self.input_path\n\n    def _is_instagram_archive(self, zip_path):\n        \"\"\"\n        Check if a ZIP file is likely an Instagram archive.\n        \"\"\"\n\n        try:\n            with zipfile.ZipFile(zip_path, \"r\") as zip_ref:\n                namelist = zip_ref.namelist()\n\n                # More flexible check - look for these directory names anywhere in the paths\n                key_dirs = [\"personal_information\", \"your_instagram_activity\"]\n                found_dirs = set()\n\n                for name in namelist:\n                    for dir_name in key_dirs:\n                        if dir_name in name.lower():\n                            found_dirs.add(dir_name)\n\n                # If we found any of the key directories, it's probably an Instagram archive\n                is_archive = len(found_dirs) > 0\n                return is_archive\n\n        except Exception as e:\n            print(f\"Error examining ZIP: {str(e)}\")\n            return False\n\n    def extract(self):\n        \"\"\"\n        Extract the Instagram archive to the specified location.\n\n        Returns:\n            str: Path to the extracted content\n\n        Raises:\n            ValueError: If no input path is specified or the file doesn't exist\n            zipfile.BadZipFile: If the ZIP file is invalid\n        \"\"\"\n        if not self.input_path:\n            raise ValueError(\n                \"No input path specified. Use auto_detect_archive() or specify input_path.\"\n            )\n\n        if not os.path.exists(self.input_path):\n            raise ValueError(f\"Input path does not exist: {self.input_path}\")\n\n        # Determine if input is a ZIP file or a directory\n        if os.path.isfile(self.input_path) and self.input_path.lower().endswith(\".zip\"):\n            # Create a temporary directory if no output_path is specified\n            if not self.output_path:\n                self.temp_dir = tempfile.mkdtemp(prefix=\"instagram_export_\")\n                self.extraction_dir = self.temp_dir\n            else:\n                self.extraction_dir = self.output_path\n                os.makedirs(self.extraction_dir, exist_ok=True)\n\n            # Extract all detected ZIP files, merging their contents\n            for zip_path in self.input_paths:\n                print(f\"Extracting {zip_path} to {self.extraction_dir}...\")\n                self._extract_and_merge(zip_path, self.extraction_dir)\n        else:\n            # Input is already a directory\n            self.extraction_dir = self.input_path\n\n        # After extraction, check if there's a single directory at the top level\n        contents = os.listdir(self.extraction_dir)\n        if len(contents) == 1 and os.path.isdir(\n            os.path.join(self.extraction_dir, contents[0])\n        ):\n            # If so, use that as the actual extraction directory\n            self.extraction_dir = os.path.join(self.extraction_dir, contents[0])\n            print(\n                f\"Found single top-level directory, using it as extraction dir: {self.extraction_dir}\"\n            )\n\n        # Now validate with the correct path\n        if self.validate_structure():\n            return self.extraction_dir\n        else:\n            raise ValueError(\n                \"Extracted content does not appear to be a valid Instagram archive.\"\n            )\n\n    def validate_structure(self):\n        \"\"\"\n        Validate the structure of the extracted content.\n        \"\"\"\n        if not self.extraction_dir or not os.path.exists(self.extraction_dir):\n            return False\n\n        # Create file mapper\n        self.file_mapper = InstagramFileMapper(self.extraction_dir)\n        self.file_mapper.discover_all_files()\n\n        # Validate required files\n        valid, missing_files = self.file_mapper.validate_required_files(\n            self.REQUIRED_FILES\n        )\n\n        if not valid:\n            print(f\"Missing required files: {', '.join(missing_files)}\")\n            return False\n\n        # For backward compatibility, update self.file_map\n        self.file_map = self.file_mapper.file_map\n        return True\n\n    def _map_important_files(self):\n        \"\"\"\n        Find and map important files that might be in different locations.\n        \"\"\"\n        for file_type, patterns in self.FILE_PATTERNS.items():\n            # Handle both single string patterns and lists of patterns\n            if isinstance(patterns, str):\n                patterns = [patterns]\n\n            all_matches = []\n            for pattern in patterns:\n                # Use Path.glob to find files matching each pattern\n                matches = list(Path(self.extraction_dir).glob(pattern))\n                all_matches.extend(matches)\n\n            if all_matches:\n                # Store the path to the first matching file\n                self.file_map[file_type] = str(all_matches[0])\n\n                # If multiple posts files are found, store them all\n                if file_type == \"posts\" and len(all_matches) > 1:\n                    self.file_map[f\"{file_type}_all\"] = [\n                        str(match) for match in all_matches\n                    ]\n\n    def _extract_and_merge(self, zip_path, target_dir):\n        \"\"\"\n        Extract a ZIP file into target_dir, handling the case where the ZIP\n        contains a single top-level directory by merging its contents directly.\n        \"\"\"\n        staging_dir = tempfile.mkdtemp(prefix=\"instagram_staging_\")\n        try:\n            with zipfile.ZipFile(zip_path, \"r\") as zip_ref:\n                zip_ref.extractall(staging_dir)\n\n            # If the ZIP had a single top-level directory, use its contents directly\n            contents = os.listdir(staging_dir)\n            if len(contents) == 1 and os.path.isdir(os.path.join(staging_dir, contents[0])):\n                source = os.path.join(staging_dir, contents[0])\n            else:\n                source = staging_dir\n\n            self._merge_dirs(source, target_dir)\n        finally:\n            shutil.rmtree(staging_dir, ignore_errors=True)\n\n    def _merge_dirs(self, src, dst):\n        \"\"\"Recursively merge src directory into dst directory.\"\"\"\n        for item in os.listdir(src):\n            s = os.path.join(src, item)\n            d = os.path.join(dst, item)\n            if os.path.isdir(s):\n                if os.path.exists(d):\n                    self._merge_dirs(s, d)\n                else:\n                    shutil.copytree(s, d)\n            else:\n                shutil.copy2(s, d)\n\n    def get_file_path(self, file_type):\n        \"\"\"\n        Get the path to an important file.\n\n        Args:\n            file_type (str): Type of file to get (e.g., \"posts\", \"insights\")\n\n        Returns:\n            str: Path to the file or None if not found\n        \"\"\"\n        return self.file_map.get(file_type)\n\n    def cleanup_temp_files(self):\n        \"\"\"\n        Clean up temporary files created during extraction.\n        \"\"\"\n        if self.cleanup and self.temp_dir and os.path.exists(self.temp_dir):\n            print(f\"Cleaning up temporary directory: {self.temp_dir}\")\n            shutil.rmtree(self.temp_dir)\n            self.temp_dir = None\n\n    def __del__(self):\n        \"\"\"\n        Ensure cleanup of temporary files when the object is destroyed.\n        \"\"\"\n        self.cleanup_temp_files()\n"
  },
  {
    "path": "memento_mori/file_mapper.py",
    "content": "# memento_mori/file_mapper.py\nfrom pathlib import Path\nimport os\n\n\nclass InstagramFileMapper:\n    \"\"\"\n    Central class for discovering and mapping Instagram export files.\n    Used by both Extractor and Loader to maintain consistency.\n    \"\"\"\n\n    # Define all patterns in one central location\n    FILE_PATTERNS = {\n        \"posts\": [\"**/content/posts*.json\", \"**/media/posts*.json\"],\n        \"insights\": [\"**/past_instagram_insights/posts.json\"],\n\t\t\"profile\": [\n\t\t\t\"**/personal_information/personal_information/personal_information.json\",  # Double-nested (newer exports)\n\t\t\t\"**/personal_information/personal_information.json\",\n\t\t\t\"**/account_information/personal_information.json\",\n\t\t\t\"**/personal_information.json\",\n\t\t\t\"**/*/personal_information.json\"\n\t\t],\n\t\t\"location\": [\n\t\t\t\"**/personal_information/information_about_you/profile_based_in.json\",  # Newer exports\n\t\t\t\"**/information_about_you/profile_based_in.json\",\n\t\t\t\"**/profile_based_in.json\",\n\t\t\t\"**/*/profile_based_in.json\",\n\t\t\t\"**/account_information/profile_based_in.json\",\n\t\t\t\"**/personal_information/profile_based_in.json\"\n\t\t],\n        \"followers\": [\n            \"**/connections/followers_and_following/followers*.json\",\n            \"**/followers_and_following/followers*.json\",\n            \"**/followers*.json\",\n            # Search in any subdirectory\n            \"**/*/followers*.json\"\n        ],\n        \"stories\": [\n            \"**/content/stories*.json\",\n            \"**/media/stories*.json\",\n            \"**/your_instagram_activity/stories*.json\",\n            \"**/stories*.json\",\n            \"**/your_instagram_activity/stories/stories*.json\",\n            \"**/your_instagram_activity/content/stories*.json\",\n            # Search in any subdirectory\n            \"**/*/stories*.json\"\n        ],\n        # Add more patterns as needed\n    }\n\n    def __init__(self, base_dir):\n        self.base_dir = Path(base_dir)\n        self.file_map = {}\n\n    def discover_all_files(self):\n        \"\"\"\n        Discover all files defined in FILE_PATTERNS.\n        \"\"\"\n        for file_type, patterns in self.FILE_PATTERNS.items():\n            self.discover_files(file_type, patterns)\n        return self.file_map\n\n    def discover_files(self, file_type, patterns=None):\n        \"\"\"\n        Discover files of a specific type.\n        \"\"\"\n        if patterns is None:\n            patterns = self.FILE_PATTERNS.get(file_type, [])\n\n        # Handle both single string patterns and lists of patterns\n        if isinstance(patterns, str):\n            patterns = [patterns]\n\n        all_matches = []\n        for pattern in patterns:\n            # First try exact path if it looks like one\n            if not pattern.startswith(\"**\"):\n                exact_path = os.path.join(self.base_dir, pattern)\n                if os.path.exists(exact_path):\n                    all_matches.append(Path(exact_path))\n                    continue\n\n            # Otherwise use Path.glob to find files matching pattern\n            matches = list(self.base_dir.glob(pattern))\n            all_matches.extend(matches)\n\n        if all_matches:\n            # Store the path to the first matching file\n            self.file_map[file_type] = str(all_matches[0])\n\n            # If multiple matches are found, store them all\n            if len(all_matches) > 1:\n                self.file_map[f\"{file_type}_all\"] = [\n                    str(match) for match in all_matches\n                ]\n\n        return self.file_map.get(file_type)\n\n    def get_file_path(self, file_type):\n        \"\"\"\n        Get the path to a specific file type.\n        \"\"\"\n        if file_type not in self.file_map and file_type in self.FILE_PATTERNS:\n            # Try to discover it if not already in the map\n            self.discover_files(file_type)\n\n        return self.file_map.get(file_type)\n\n    def validate_required_files(self, required_files):\n        \"\"\"\n        Validate that all required files exist.\n        \"\"\"\n        missing_files = []\n        for file_type in required_files:\n            if not self.get_file_path(file_type):\n                missing_files.append(file_type)\n\n        return len(missing_files) == 0, missing_files\n"
  },
  {
    "path": "memento_mori/generator.py",
    "content": "# memento_mori/generator.py\nimport os\nimport json\nimport shutil\nimport datetime\nfrom pathlib import Path\nfrom jinja2 import Environment, FileSystemLoader\nfrom markupsafe import Markup\nimport re\nimport hashlib\nimport base64\n\n\nclass InstagramSiteGenerator:\n    \"\"\"\n    Class for generating the static website from processed Instagram data.\n\n    This class handles:\n    - Creating HTML using templates\n    - Copying static assets (CSS, JS)\n    - Verifying the completeness of the output\n    \"\"\"\n\n    def __init__(self, data_package, output_dir, template_dir=None, static_dir=None, gtag_id=None):\n        \"\"\"Initialize the generator with data and path options.\"\"\"\n        self.data_package = data_package\n        self.output_dir = Path(output_dir)\n        self.gtag_id = gtag_id  # Store the Google tag ID\n\n        # Find template directory\n        if template_dir is None:\n            # Try to find templates relative to this file or common locations\n            module_dir = Path(__file__).parent\n            template_dir = module_dir / \"templates\"\n\n            if not template_dir.exists():\n                for path in [\n                    Path(\"templates\"),\n                    Path(\"./templates\"),\n                    Path(\"../templates\"),\n                ]:\n                    if path.exists():\n                        template_dir = path\n                        break\n\n        # Find static directory\n        if static_dir is None:\n            module_dir = Path(__file__).parent\n            static_dir = module_dir / \"static\"\n\n            if not static_dir.exists():\n                for path in [Path(\"static\"), Path(\"./static\"), Path(\"../static\")]:\n                    if path.exists():\n                        static_dir = path\n                        break\n\n        self.template_dir = Path(template_dir)\n        self.static_dir = Path(static_dir)\n\n        print(f\"Using template directory: {self.template_dir}\")\n        print(f\"Using static directory: {self.static_dir}\")\n\n        # Set up Jinja environment\n        self.jinja_env = Environment(\n            loader=FileSystemLoader(str(self.template_dir)), autoescape=True\n        )\n\n    def generate(self):\n        \"\"\"Generate the complete static website and verify output.\"\"\"\n        try:\n            # Create output directory\n            self.output_dir.mkdir(parents=True, exist_ok=True)\n\n            # Create CSS and JS directories in output\n            (self.output_dir / \"css\").mkdir(exist_ok=True)\n            (self.output_dir / \"js\").mkdir(exist_ok=True)\n\n            # Copy static assets\n            self._copy_static_assets()\n\n            # Generate HTML\n            self._generate_html()\n            \n            # Generate stories HTML if we have stories data\n            if \"stories\" in self.data_package and self.data_package[\"stories\"]:\n                self._generate_stories_html()\n\n            print(f\"Website successfully generated at {self.output_dir}\")\n            return True\n\n        except Exception as e:\n            print(f\"Error generating website: {str(e)}\")\n            return False\n\n    def _copy_static_assets(self):\n        \"\"\"Copy CSS and JS files to the output directory.\"\"\"\n        # Copy CSS\n        css_dir = self.static_dir / \"css\"\n        if css_dir.exists():\n            for css_file in css_dir.glob(\"*.css\"):\n                shutil.copy2(css_file, self.output_dir / \"css\" / css_file.name)\n                print(f\"Copied CSS: {css_file.name}\")\n\n        # Copy JS\n        js_dir = self.static_dir / \"js\"\n        if js_dir.exists():\n            for js_file in js_dir.glob(\"*.js\"):\n                shutil.copy2(js_file, self.output_dir / \"js\" / js_file.name)\n                print(f\"Copied JS: {js_file.name}\")\n            \n            # Ensure stories.js exists, create it if not\n            stories_js = js_dir / \"stories.js\"\n            if not stories_js.exists():\n                # Create a minimal stories.js file if it doesn't exist\n                with open(stories_js, \"w\") as f:\n                    f.write(\"// Stories viewer functionality\\n\")\n                print(f\"Created placeholder: stories.js\")\n            \n            # Copy stories.js to output\n            shutil.copy2(stories_js, self.output_dir / \"js\" / \"stories.js\")\n            print(f\"Copied JS: stories.js\")\n\n    def _generate_html(self):\n        \"\"\"Generate HTML using templates.\"\"\"\n        # Generate the grid HTML\n        grid_html = self._render_grid()\n\n        # Extract data for the main template\n        profile_info = self.data_package[\"profile\"]\n        location_info = self.data_package.get(\"location\", {\"location\": \"Unknown\"})\n        date_range = self.data_package[\"date_range\"][\"range\"]\n        post_count = self.data_package[\"post_count\"]\n        story_count = self.data_package.get(\"story_count\", 0)\n        \n        # Get profile picture path and check for WebP version\n        profile_picture = profile_info[\"profile_picture\"]\n        \n        # Check if we have a WebP version of the profile picture\n        if profile_picture:\n            webp_path = re.sub(r\"\\.(jpg|jpeg|png|gif)$\", \".webp\", profile_picture, flags=re.I)\n            if os.path.exists(os.path.join(self.output_dir, webp_path)):\n                profile_picture = webp_path\n\n        # Current date for footer\n        generation_date = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n\n        # Get stories data or empty dict if not available\n        stories_data = self.data_package.get(\"stories\", {})\n\n        # Render the main template\n        template = self.jinja_env.get_template(\"index.html\")\n        html_content = template.render(\n            username=profile_info[\"username\"],\n            profile_picture=profile_picture,\n            bio=profile_info.get(\"bio\", \"\"),  # Pass bio to template\n            profile=profile_info,  # Pass the entire profile object\n            date_range=date_range,\n            post_count=post_count,\n            story_count=story_count,\n            has_stories=story_count > 0,  # Flag to show stories link\n            grid_html=grid_html,\n            post_data_json=json.dumps(self.data_package[\"posts\"], ensure_ascii=False),\n            stories_data_json=json.dumps(stories_data, ensure_ascii=False),  # Add stories data\n            generation_date=generation_date,\n            gtag_id=self.gtag_id,  # Add Google tag ID\n        )\n\n        # Write HTML file\n        with open(self.output_dir / \"index.html\", \"w\", encoding=\"utf-8\") as f:\n            f.write(html_content)\n\n        print(f\"Generated HTML file: {self.output_dir / 'index.html'}\")\n\n    def _render_grid(self):\n        \"\"\"Render the grid HTML using the grid.html template.\"\"\"\n        posts_data = self.data_package[\"posts\"]\n        lazy_after = 30  # Start lazy loading after this many posts\n\n        # Check if posts_data is valid\n        if not posts_data or not isinstance(posts_data, dict):\n            print(\"Warning: No valid posts data found for grid rendering\")\n            return \"\"\n\n        # Prepare data for the grid template\n        grid_posts = []\n        for i, (timestamp, post) in enumerate(posts_data.items()):\n            # Determine which media to use for the grid thumbnail\n            display_media = self._get_display_media(post, i >= lazy_after)\n\n            grid_posts.append(\n                {\n                    \"index\": post[\"i\"],\n                    \"display_media\": display_media[\"url\"],\n                    \"is_video\": display_media[\"is_video\"],\n                    \"media_count\": len(post[\"m\"]),\n                    \"likes\": post.get(\"l\", \"\"),\n                    \"lazy_load\": Markup(' loading=\"lazy\"') if i >= lazy_after else \"\",\n                }\n            )\n\n        # Render grid template\n        grid_template = self.jinja_env.get_template(\"grid.html\")\n        return grid_template.render(posts=grid_posts)\n\n    def _get_display_media(self, post, use_lazy_loading=False):\n        \"\"\"Determine which media to use for the grid thumbnail.\"\"\"\n        result = {\"url\": \"\", \"is_video\": False}\n\n        if not post[\"m\"] or len(post[\"m\"]) == 0:\n            return result\n\n        first_media = post[\"m\"][0]\n        result[\"url\"] = first_media\n\n        # Check if first media is a video\n        result[\"is_video\"] = bool(\n            re.search(r\"\\.(mp4|mov|avi|webm)$\", first_media, re.I)\n            if first_media\n            else False\n        )\n\n        # Check if we have a thumbnail for this media\n        if first_media:\n            thumb_filename = hashlib.md5(first_media.encode()).hexdigest() + \".webp\"\n            thumb_path = f\"thumbnails/{thumb_filename}\"\n\n            if os.path.exists(os.path.join(self.output_dir, thumb_path)):\n                # Use the thumbnail instead of the original\n                result[\"url\"] = thumb_path\n            elif not result[\"is_video\"]:\n                # Check if we have a WebP version of the original image\n                webp_path = re.sub(\n                    r\"\\.(jpg|jpeg|png|gif)$\", \".webp\", first_media, flags=re.I\n                )\n                if os.path.exists(os.path.join(self.output_dir, webp_path)):\n                    result[\"url\"] = webp_path\n\n            # If it's a video, look for a thumbnail among all media items\n            if (\n                result[\"is_video\"] and result[\"url\"] == first_media\n            ):  # No thumbnail found yet\n                for media_item in post[\"m\"]:\n                    if re.search(r\"\\.(jpg|jpeg|png|webp|gif)$\", media_item, re.I):\n                        # Check if we have a thumbnail for this image\n                        img_thumb_filename = (\n                            hashlib.md5(media_item.encode()).hexdigest() + \".webp\"\n                        )\n                        img_thumb_path = f\"thumbnails/{img_thumb_filename}\"\n\n                        if os.path.exists(\n                            os.path.join(self.output_dir, img_thumb_path)\n                        ):\n                            result[\"url\"] = img_thumb_path\n                            break\n                        else:\n                            result[\"url\"] = media_item\n                            break\n\n                # If no thumbnail found, use a SVG placeholder\n                if result[\"url\"] == first_media:\n                    # Create a simple SVG with a play button\n                    svg = (\n                        '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"400\" viewBox=\"0 0 400 400\">'\n                        '<rect width=\"400\" height=\"400\" fill=\"#333333\"/>'\n                        '<circle cx=\"200\" cy=\"200\" r=\"60\" fill=\"#ffffff\" fill-opacity=\"0.8\"/>'\n                        '<polygon points=\"180,160 180,240 240,200\" fill=\"#333333\"/>'\n                        \"</svg>\"\n                    )\n\n                    # Encode the SVG properly for use in an img src attribute\n                    result[\"url\"] = (\n                        \"data:image/svg+xml;base64,\"\n                        + base64.b64encode(svg.encode()).decode()\n                    )\n\n        return result\n    def _generate_stories_html(self):\n        \"\"\"Generate a separate HTML file for stories.\"\"\"\n        stories_data = self.data_package.get(\"stories\", {})\n        \n        if not stories_data:\n            print(\"No stories data found, skipping stories.html generation\")\n            return\n        \n        # Extract data for the stories template\n        profile_info = self.data_package[\"profile\"]\n        date_range = self.data_package[\"date_range\"][\"range\"]\n        story_count = len(stories_data)\n        post_count = self.data_package[\"post_count\"]\n        \n        # Get profile picture path and check for WebP version\n        profile_picture = profile_info[\"profile_picture\"]\n        \n        # Check if we have a WebP version of the profile picture\n        if profile_picture:\n            webp_path = re.sub(r\"\\.(jpg|jpeg|png|gif)$\", \".webp\", profile_picture, flags=re.I)\n            if os.path.exists(os.path.join(self.output_dir, webp_path)):\n                profile_picture = webp_path\n\n        # Current date for footer\n        generation_date = datetime.datetime.now().strftime(\"%Y-%m-%d\")\n        \n        # Prepare stories data for the template\n        stories_list = []\n        lazy_after = 30  # Start lazy loading after this many stories\n        \n        for i, (timestamp, story) in enumerate(stories_data.items()):\n            # Check for story-specific thumbnail\n            story_thumb = story.get(\"story_thumb\", None)\n            \n            if story_thumb and os.path.exists(os.path.join(self.output_dir, story_thumb)):\n                # Use the 9:16 story thumbnail\n                media_url = story_thumb\n            else:\n                # Fall back to regular thumbnail or original media\n                display_media = self._get_display_media(story, i >= lazy_after)\n                media_url = display_media[\"url\"]\n            \n            # Determine if it's a video\n            is_video = bool(re.search(r\"\\.(mp4|mov|avi|webm)$\", story[\"m\"][0], re.I)) if story[\"m\"] else False\n            \n            stories_list.append({\n                \"index\": story[\"i\"],\n                \"media\": media_url,\n                \"is_video\": is_video,\n                \"date\": story.get(\"d\", \"\"),\n                \"caption\": story.get(\"tt\", \"\"),\n                \"timestamp\": timestamp,\n                \"lazy_load\": Markup(' loading=\"lazy\"') if i >= lazy_after else \"\",\n                \"original_media\": story[\"m\"][0] if story[\"m\"] else \"\",  # Include original media path\n            })\n        \n        # Render the stories template\n        template = self.jinja_env.get_template(\"stories_page.html\")\n        html_content = template.render(\n            username=profile_info[\"username\"],\n            profile_picture=profile_picture,\n            bio=profile_info.get(\"bio\", \"\"),\n            profile=profile_info,\n            date_range=date_range,\n            post_count=post_count,\n            story_count=story_count,\n            stories=stories_list,\n            stories_data_json=json.dumps(stories_data, ensure_ascii=False),\n            generation_date=generation_date,\n            gtag_id=self.gtag_id,\n        )\n        \n        # Write HTML file\n        with open(self.output_dir / \"stories.html\", \"w\", encoding=\"utf-8\") as f:\n            f.write(html_content)\n        \n        print(f\"Generated stories HTML file: {self.output_dir / 'stories.html'}\")\n"
  },
  {
    "path": "memento_mori/loader.py",
    "content": "# memento_mori/loader.py\nimport json\nimport re\nimport os\nfrom datetime import datetime\nimport html\nfrom ftfy import fix_text\nfrom pathlib import Path\n\n\ndef fix_double_encoded_utf8(text):\n    \"\"\"\n    Fix double-encoded UTF-8 sequences in text using ftfy.\n    This handles cases where UTF-8 characters (like emoji) were incorrectly encoded twice.\n    \"\"\"\n    if not isinstance(text, str):\n        return text\n    \n    # Use ftfy to fix the text encoding issues\n    return fix_text(text)\n\n\nclass InstagramDataLoader:\n    \"\"\"\n    Class for loading and processing Instagram data from the exported archive.\n\n    This class provides methods to:\n    - Load JSON files (posts, insights, user data)\n    - Parse and merge data sources\n    - Convert timestamps and format data\n    - Provide a clean data structure for the generator\n    \"\"\"\n\n    def __init__(self, extraction_dir, file_mapper=None, verbose=False):\n        \"\"\"\n        Initialize the loader with the path to the extracted data.\n\n        Args:\n            extraction_dir (str): Path to the extracted Instagram data\n            file_mapper (InstagramFileMapper, optional): File mapper from extractor\n            verbose (bool): Whether to print verbose debug information\n        \"\"\"\n        self.extraction_dir = extraction_dir\n        self.file_mapper = file_mapper\n        self.verbose = verbose\n\n        # If no file mapper was provided, create one\n        if self.file_mapper is None:\n            from .file_mapper import InstagramFileMapper\n\n            self.file_mapper = InstagramFileMapper(extraction_dir)\n            self.file_mapper.discover_all_files()\n\n        # Storage for loaded data\n        self.profile_data = None\n        self.location_data = None\n        self.posts_data = None\n        self.insights_data = None\n        self.combined_data = None\n\n    def load_profile_data(self):\n        \"\"\"\n        Load user profile data.\n\n        Returns:\n            dict: User profile information\n        \"\"\"\n        profile_path = self.file_mapper.get_file_path(\"profile\")\n        if not profile_path:\n            print(\"Profile data not found\")\n            return {\"username\": \"Unknown\", \"profile_picture\": \"\", \"bio\": \"\"}\n\n        try:\n            with open(profile_path, \"r\", encoding=\"utf-8\") as f:\n                self.profile_data = json.load(f)\n\n            string_map = self.profile_data[\"profile_user\"][0][\"string_map_data\"]\n            media_map = self.profile_data[\"profile_user\"][0][\"media_map_data\"]\n\n            profile_info = {\n                \"username\": string_map[\"Username\"][\"value\"],\n                \"profile_picture\": \"\",\n                \"bio\": \"\",\n                \"website\": \"\",\n                \"name\": \"\",\n            }\n\n            for key, value in media_map.items():\n                if key.lower() == \"profile photo\":\n                    profile_info[\"profile_picture\"] = value.get(\"uri\", \"\")\n                    break\n\n            if \"Name\" in string_map:\n                profile_info[\"name\"] = string_map[\"Name\"][\"value\"]\n\n            if \"Bio\" in string_map:\n                profile_info[\"bio\"] = string_map[\"Bio\"][\"value\"]\n\n            if \"Website\" in string_map:\n                profile_info[\"website\"] = string_map[\"Website\"][\"value\"]\n\n            return profile_info\n        except Exception as e:\n            print(f\"Error loading profile data: {str(e)}\")\n            return {\"username\": \"Unknown\", \"profile_picture\": \"\"}\n\n    def load_location_data(self):\n        \"\"\"\n        Load user location data.\n\n        Returns:\n            dict: User location information\n        \"\"\"\n        location_path = self.file_mapper.get_file_path(\"location\")\n        if not location_path:\n            print(\"Location data not found\")\n            return {\"location\": \"Unknown\"}\n\n        try:\n            with open(location_path, \"r\", encoding=\"utf-8\") as f:\n                self.location_data = json.load(f)\n\n            string_map = self.location_data[\"inferred_data_primary_location\"][0][\"string_map_data\"]\n\n            location_value = \"Unknown\"\n            for key in [\"Town/city name\", \"City Name\", \"Name\"]:\n                if key in string_map:\n                    location_value = string_map[key][\"value\"]\n                    break\n\n            return {\"location\": location_value}\n\n            return location_info\n        except Exception as e:\n            print(f\"Error loading location data: {str(e)}\")\n            return {\"location\": \"Unknown\"}\n\n    def load_posts_data(self):\n        \"\"\"\n        Load posts data from one or more posts JSON files.\n\n        Returns:\n            list: Combined posts data from all posts files\n        \"\"\"\n        all_posts = []\n\n        # Check if we have multiple post files\n        post_paths = []\n        if self.file_mapper.file_map.get(\"posts_all\"):\n            post_paths = self.file_mapper.file_map[\"posts_all\"]\n        elif self.file_mapper.get_file_path(\"posts\"):\n            post_paths = [self.file_mapper.get_file_path(\"posts\")]\n\n        if not post_paths:\n            print(\"No posts data found\")\n            return []\n\n        if self.verbose:\n            print(f\"Found {len(post_paths)} posts data file(s):\")\n            for i, path in enumerate(post_paths):\n                print(f\"  {i+1}. {path}\")\n\n        for posts_path in post_paths:\n            try:\n                if self.verbose:\n                    print(f\"Loading posts from: {posts_path}\")\n                \n                with open(posts_path, \"r\", encoding=\"utf-8\") as f:\n                    # Read the file content first\n                    file_content = f.read()\n                    \n                    if self.verbose:\n                        print(f\"  File size: {len(file_content)} bytes\")\n                    \n                    # Fix encoding issues with ftfy\n                    file_content = fix_text(file_content)\n                    \n                    # Parse the modified content\n                    posts_data = json.loads(file_content, strict=False)\n                    \n                    # Check if posts_data is a list (expected format)\n                    if isinstance(posts_data, list):\n                        if self.verbose:\n                            print(f\"  Found {len(posts_data)} posts in list format\")\n                        all_posts.extend(posts_data)\n                    elif isinstance(posts_data, dict):\n                        # Some exports might have posts as a dictionary\n                        if self.verbose:\n                            print(f\"  Found posts in dictionary format\")\n                            print(f\"  Dictionary keys: {', '.join(list(posts_data.keys())[:5])}...\")\n                        \n                        # Try to extract a list from it\n                        if \"posts\" in posts_data and isinstance(posts_data[\"posts\"], list):\n                            if self.verbose:\n                                print(f\"  Found {len(posts_data['posts'])} posts in 'posts' key\")\n                            all_posts.extend(posts_data[\"posts\"])\n                        else:\n                            # Add the dict as a single item if we can't extract a list\n                            if self.verbose:\n                                print(f\"  No 'posts' list found, adding dictionary as a single item\")\n                            all_posts.append(posts_data)\n                    else:\n                        print(f\"Warning: Unexpected posts data format in {posts_path}\")\n                        if self.verbose:\n                            print(f\"  Data type: {type(posts_data)}\")\n            except Exception as e:\n                print(f\"Error loading posts data from {posts_path}: {str(e)}\")\n                if self.verbose:\n                    import traceback\n                    traceback.print_exc()\n\n        if not all_posts:\n            print(\"Warning: No posts data could be loaded from any file\")\n        elif self.verbose:\n            print(f\"Successfully loaded {len(all_posts)} posts in total\")\n            \n        self.posts_data = all_posts\n        return all_posts\n\n    def load_insights_data(self):\n        \"\"\"\n        Load insights data.\n\n        Returns:\n            dict: Insights data indexed by timestamp\n        \"\"\"\n        insights_path = self.file_mapper.get_file_path(\"insights\")\n        if not insights_path:\n            print(\n                \"Warning: No insights file found. Insights data will not be available.\"\n            )\n            # Initialize as empty dict, not None\n            self.insights_data = {}\n            return {}\n\n        try:\n            with open(insights_path, \"r\", encoding=\"utf-8\") as f:\n                file_content = f.read()\n                # Fix encoding issues\n                file_content = fix_text(file_content)\n                insights_raw = json.loads(file_content, strict=False)\n\n            # Index insights by timestamp\n            insights_indexed = {}\n            \n            # Handle different possible structures\n            if \"organic_insights_posts\" in insights_raw:\n                for insight in insights_raw.get(\"organic_insights_posts\", []):\n                    timestamp = None\n                    \n                    # Try to get timestamp from media_map_data\n                    if \"media_map_data\" in insight and \"Media Thumbnail\" in insight[\"media_map_data\"]:\n                        timestamp = insight[\"media_map_data\"][\"Media Thumbnail\"].get(\"creation_timestamp\")\n                    \n                    # If no timestamp yet, try other fields\n                    if not timestamp and \"creation_timestamp\" in insight:\n                        timestamp = insight[\"creation_timestamp\"]\n                    \n                    if timestamp:\n                        insights_indexed[str(timestamp)] = insight\n            else:\n                # Try alternative structure\n                for insight in insights_raw:\n                    if isinstance(insight, dict) and \"creation_timestamp\" in insight:\n                        timestamp = insight[\"creation_timestamp\"]\n                        insights_indexed[str(timestamp)] = insight\n\n            self.insights_data = insights_indexed\n            return insights_indexed\n        except Exception as e:\n            print(f\"Error loading insights data: {str(e)}\")\n            self.insights_data = {}\n            return {}\n\n    def combine_data(self):\n        \"\"\"\n        Combine posts and insights data.\n\n        Returns:\n            list: Combined data with posts and their associated insights\n        \"\"\"\n        if self.posts_data is None:\n            if self.verbose:\n                print(\"No posts data yet, loading posts data\")\n            self.load_posts_data()\n\n        if self.insights_data is None:\n            if self.verbose:\n                print(\"No insights data yet, loading insights data\")\n            self.load_insights_data()\n\n        # Ensure insights_data is a dictionary\n        if not isinstance(self.insights_data, dict):\n            if self.verbose:\n                print(\"Warning: insights_data is not a dictionary, initializing as empty\")\n            self.insights_data = {}\n\n        if self.verbose:\n            print(f\"Combining {len(self.posts_data) if self.posts_data else 0} posts with {len(self.insights_data)} insights entries\")\n\n        combined = []\n        \n        # Create a mapping of timestamps to insights for faster lookup\n        insights_map = {}\n        for timestamp, insight in self.insights_data.items():\n            insights_map[str(timestamp)] = insight\n\n        if not self.posts_data:\n            if self.verbose:\n                print(\"Warning: No posts data to combine\")\n            self.combined_data = []\n            return []\n\n        for post in self.posts_data:\n            try:\n                # Get the timestamp from the first media item\n                timestamp = None\n                if \"media\" in post and len(post[\"media\"]) > 0 and \"creation_timestamp\" in post[\"media\"][0]:\n                    timestamp = str(post[\"media\"][0][\"creation_timestamp\"])\n                elif \"creation_timestamp\" in post:\n                    timestamp = str(post[\"creation_timestamp\"])\n                \n                # Find associated insights\n                insight = insights_map.get(timestamp) if timestamp else None\n                \n                # Create combined entry\n                combined.append({\"post_data\": post, \"insights\": insight})\n                \n                if self.verbose and not timestamp:\n                    print(f\"Warning: Post without timestamp\")\n                    print(f\"  Post keys: {', '.join(list(post.keys())[:5])}...\")\n                    if \"media\" in post:\n                        print(f\"  Media items: {len(post['media'])}\")\n                        if len(post[\"media\"]) > 0:\n                            print(f\"  First media keys: {', '.join(list(post['media'][0].keys())[:5])}...\")\n                \n            except (IndexError, KeyError) as e:\n                print(f\"Error processing post: {str(e)}\")\n                if self.verbose:\n                    import traceback\n                    traceback.print_exc()\n                    print(f\"  Post keys: {', '.join(list(post.keys())[:5])}...\")\n                # Add post without insights\n                combined.append({\"post_data\": post, \"insights\": None})\n\n        if self.verbose:\n            print(f\"Created {len(combined)} combined entries\")\n            \n        self.combined_data = combined\n        return combined\n\n    def extract_relevant_data(self):\n        \"\"\"\n        Extract relevant data from the combined posts and insights data.\n\n        Returns:\n            dict: Simplified data structure with relevant information\n        \"\"\"\n        if self.combined_data is None:\n            if self.verbose:\n                print(\"No combined data yet, calling combine_data()\")\n            self.combine_data()\n            \n        # Check if combined_data is still None or empty after trying to combine\n        if not self.combined_data:\n            print(\"Warning: No post data found or could not be processed.\")\n            if self.verbose:\n                print(\"combined_data is None or empty after combine_data() call\")\n                print(f\"posts_data: {type(self.posts_data)}, length: {len(self.posts_data) if self.posts_data else 0}\")\n                print(f\"insights_data: {type(self.insights_data)}, length: {len(self.insights_data) if self.insights_data else 0}\")\n            return {}\n\n        if self.verbose:\n            print(f\"Processing {len(self.combined_data)} combined data entries\")\n\n        simplified_data = {}\n\n        for index, item in enumerate(self.combined_data):\n            # Initialize a new post entry with shortened keys\n            post_entry = {\n                \"i\": index,  # post_index\n                \"m\": [],     # media\n                \"t\": \"\",     # creation_timestamp_unix\n                \"d\": \"\",     # creation_timestamp_readable\n                \"tt\": \"\",    # title\n                \"im\": \"\",    # Impressions\n                \"l\": \"\",     # Likes\n                \"c\": \"\",     # Comments\n            }\n\n            # Extract post-level data\n            if \"post_data\" in item:\n                if \"creation_timestamp\" in item[\"post_data\"]:\n                    post_entry[\"t\"] = item[\"post_data\"][\"creation_timestamp\"]\n                elif \"media\" in item[\"post_data\"] and len(item[\"post_data\"][\"media\"]) > 0 and \"creation_timestamp\" in item[\"post_data\"][\"media\"][0]:\n                    # Fallback to first media item timestamp if post timestamp not available\n                    post_entry[\"t\"] = item[\"post_data\"][\"media\"][0][\"creation_timestamp\"]\n\n                post_entry[\"d\"] = datetime.utcfromtimestamp(\n                    post_entry[\"t\"]\n                ).strftime(\"%B %d, %Y at %I:%M %p\")\n\n                # Get title from post data\n                post_title = \"\"\n                # Check for title directly in post_data\n                if \"title\" in item[\"post_data\"] and item[\"post_data\"][\"title\"]:\n                    post_title = item[\"post_data\"][\"title\"]\n                    if isinstance(post_title, str):\n                        # Use ftfy to fix text encoding issues\n                        post_title = fix_text(post_title)\n                        # Then unescape HTML entities\n                        post_title = html.unescape(post_title)\n                \n                # Check for title in media items\n                if not post_title and \"media\" in item[\"post_data\"]:\n                    for media_item in item[\"post_data\"][\"media\"]:\n                        if \"title\" in media_item and media_item[\"title\"]:\n                            post_title = media_item[\"title\"]\n                            if isinstance(post_title, str):\n                                post_title = fix_text(post_title)\n                                post_title = html.unescape(post_title)\n                            break  # Use the first media item with a title\n\n                # Extract media URIs\n                if \"media\" in item[\"post_data\"]:\n                    for media in item[\"post_data\"][\"media\"]:\n                        if \"uri\" in media:\n                            post_entry[\"m\"].append(media[\"uri\"])\n                        else:\n                            if self.verbose:\n                                print(f\"Warning: Media item without URI at post index {index}\")\n                                print(f\"  Media keys: {', '.join(list(media.keys())[:5])}...\")\n                            post_entry[\"m\"].append(\"\")\n\n            # Get insights data if available\n            insights_title = \"\"\n            if \"insights\" in item and item[\"insights\"]:\n                insights = item[\"insights\"]\n                \n                # Try to get caption from insights\n                if \"string_map_data\" in insights:\n                    insights_data = insights[\"string_map_data\"]\n                    \n                    # Extract specific metrics and ensure they're integers or blank\n                    if \"Impressions\" in insights_data:\n                        impressions = insights_data[\"Impressions\"].get(\"value\", \"\")\n                        # Validate and convert to integer if numeric, otherwise leave blank\n                        post_entry[\"im\"] = int(impressions) if impressions and impressions.isdigit() else \"\"\n\n                    if \"Likes\" in insights_data:\n                        likes = insights_data[\"Likes\"].get(\"value\", \"\")\n                        # Validate and convert to integer if numeric, otherwise leave blank\n                        post_entry[\"l\"] = int(likes) if likes and likes.isdigit() else \"\"\n\n                    if \"Comments\" in insights_data:\n                        comments = insights_data[\"Comments\"].get(\"value\", \"\")\n                        # Validate and convert to integer if numeric, otherwise leave blank\n                        post_entry[\"c\"] = int(comments) if comments and comments.isdigit() else \"\"\n                    \n                    # Try to get caption from insights\n                    if \"Caption\" in insights_data and insights_data[\"Caption\"].get(\"value\"):\n                        insights_title = insights_data[\"Caption\"].get(\"value\", \"\")\n                        if isinstance(insights_title, str):\n                            insights_title = fix_text(insights_title)\n                            insights_title = html.unescape(insights_title)\n                \n                # Check for title directly in insights\n                if not insights_title and \"title\" in insights and insights[\"title\"]:\n                    insights_title = insights[\"title\"]\n                    if isinstance(insights_title, str):\n                        insights_title = fix_text(insights_title)\n                        insights_title = html.unescape(insights_title)\n                \n                # Check for title in media_map_data\n                if not insights_title and \"media_map_data\" in insights:\n                    for media_key, media_data in insights[\"media_map_data\"].items():\n                        if \"title\" in media_data and media_data[\"title\"]:\n                            insights_title = media_data[\"title\"]\n                            if isinstance(insights_title, str):\n                                insights_title = fix_text(insights_title)\n                                insights_title = html.unescape(insights_title)\n                            break  # Use the first media item with a title\n\n            # Use the longer or non-empty title between post data and insights\n            if post_title and insights_title:\n                post_entry[\"tt\"] = post_title if len(post_title) >= len(insights_title) else insights_title\n            elif post_title:\n                post_entry[\"tt\"] = post_title\n            elif insights_title:\n                post_entry[\"tt\"] = insights_title\n\n            # Only add posts with valid timestamps\n            if post_entry[\"t\"]:\n                simplified_data[post_entry[\"t\"]] = post_entry\n            elif self.verbose:\n                print(f\"Skipping post at index {index} due to missing timestamp\")\n\n        if self.verbose:\n            print(f\"Extracted {len(simplified_data)} posts with valid timestamps\")\n            \n        # Sort by timestamp (newest first)\n        sorted_data = dict(sorted(simplified_data.items(), key=lambda x: x[0], reverse=True))\n        \n        if self.verbose and sorted_data:\n            print(f\"Posts date range: {datetime.utcfromtimestamp(int(list(sorted_data.keys())[-1])).strftime('%Y-%m-%d')} to {datetime.utcfromtimestamp(int(list(sorted_data.keys())[0])).strftime('%Y-%m-%d')}\")\n            \n        return sorted_data\n\n    def load_followers_data(self):\n        \"\"\"\n        Load followers data and count the number of followers.\n\n        Returns:\n            int: Number of followers\n        \"\"\"\n        followers_path = self.file_mapper.get_file_path(\"followers\")\n        if not followers_path:\n            if self.verbose:\n                print(\"Followers data not found\")\n            return 0\n\n        try:\n            with open(followers_path, \"r\", encoding=\"utf-8\") as f:\n                file_content = f.read()\n                # Fix encoding issues\n                file_content = fix_text(file_content)\n                followers_data = json.loads(file_content, strict=False)\n\n            # Count the number of followers\n            follower_count = len(followers_data)\n            \n            if self.verbose:\n                print(f\"Found {follower_count} followers\")\n                \n            return follower_count\n        except Exception as e:\n            print(f\"Error loading followers data: {str(e)}\")\n            return 0\n            \n    def process_json_strings(self, data):\n        \"\"\"\n        Recursively process all string values in JSON data to fix encoding issues.\n        \"\"\"\n        if isinstance(data, dict):\n            return {k: self.process_json_strings(v) for k, v in data.items()}\n        elif isinstance(data, list):\n            return [self.process_json_strings(item) for item in data]\n        elif isinstance(data, str):\n            # Apply all string fixes\n            # Use ftfy to fix text encoding issues\n            fixed = fix_text(data)\n            # Still apply HTML unescaping after fixing encoding\n            fixed = html.unescape(fixed)\n            return fixed\n        else:\n            return data\n\n    def load_stories_data(self):\n        \"\"\"\n        Load stories data from stories JSON files.\n\n        Returns:\n            dict: Processed stories data\n        \"\"\"\n        stories_path = self.file_mapper.get_file_path(\"stories\")\n        if not stories_path:\n            if self.verbose:\n                print(\"\\n🔍 STORIES DATA SEARCH\")\n                print(\"   No stories file found in standard locations\")\n                print(\"   Checking all patterns for stories files:\")\n                for pattern in self.file_mapper.FILE_PATTERNS[\"stories\"]:\n                    print(f\"   - Searching with pattern: {pattern}\")\n                    matches = list(Path(self.file_mapper.base_dir).glob(pattern))\n                    if matches:\n                        print(f\"     Found {len(matches)} matches:\")\n                        for match in matches[:3]:  # Show first 3\n                            print(f\"     • {match}\")\n                        if len(matches) > 3:\n                            print(f\"     • ... and {len(matches)-3} more\")\n                    else:\n                        print(f\"     No matches found\")\n                \n                # Try a more aggressive search\n                print(\"\\n   Performing deep search for any files containing 'stories':\")\n                for root, dirs, files in os.walk(self.file_mapper.base_dir):\n                    for file in files:\n                        if 'stories' in file.lower() and file.endswith('.json'):\n                            print(f\"     • Found potential stories file: {os.path.join(root, file)}\")\n            return {}\n\n        try:\n            if self.verbose:\n                print(f\"\\n🔍 STORIES DATA LOADING\")\n                print(f\"   Found stories file: {stories_path}\")\n                file_size = os.path.getsize(stories_path)\n                print(f\"   File size: {file_size} bytes\")\n            \n            with open(stories_path, \"r\", encoding=\"utf-8\") as f:\n                file_content = f.read()\n                # Fix encoding issues\n                file_content = fix_text(file_content)\n                \n                if self.verbose:\n                    print(f\"   Parsing JSON content...\")\n                \n                stories_data = json.loads(file_content, strict=False)\n                \n                if self.verbose:\n                    print(f\"   JSON parsed successfully\")\n                    if isinstance(stories_data, dict):\n                        print(f\"   Data structure: Dictionary with {len(stories_data)} keys\")\n                        print(f\"   Top-level keys: {', '.join(list(stories_data.keys())[:5])}\")\n                    elif isinstance(stories_data, list):\n                        print(f\"   Data structure: List with {len(stories_data)} items\")\n                    else:\n                        print(f\"   Data structure: {type(stories_data)}\")\n\n            # Process stories data similar to posts\n            simplified_stories = {}\n            \n            # Handle different possible structures\n            stories_list = []\n            \n            if self.verbose:\n                print(f\"\\n   Extracting stories list from data structure...\")\n            \n            # Check for \"ig_stories\" key specifically\n            if isinstance(stories_data, dict) and \"ig_stories\" in stories_data:\n                stories_list = stories_data[\"ig_stories\"]\n                if self.verbose:\n                    print(f\"   Found stories in 'ig_stories' key: {len(stories_list)} items\")\n            # Also keep the existing checks for other formats\n            elif isinstance(stories_data, list):\n                stories_list = stories_data\n                if self.verbose:\n                    print(f\"   Using top-level list with {len(stories_list)} items\")\n            elif isinstance(stories_data, dict):\n                # Try different possible keys where stories might be stored\n                possible_keys = [\"stories\", \"story_activities\", \"story_media\", \"items\"]\n                for key in possible_keys:\n                    if key in stories_data and isinstance(stories_data[key], list):\n                        stories_list = stories_data[key]\n                        if self.verbose:\n                            print(f\"   Found stories in '{key}' key: {len(stories_list)} items\")\n                        break\n                \n                if not stories_list and self.verbose:\n                    print(f\"   Could not find stories list in dictionary keys\")\n                    print(f\"   Available keys: {', '.join(list(stories_data.keys()))}\")\n            \n            if self.verbose:\n                print(f\"\\n   Processing {len(stories_list)} stories...\")\n            \n            for index, story in enumerate(stories_list):\n                # Initialize a new story entry with shortened keys\n                story_entry = {\n                    \"i\": index,  # story_index\n                    \"m\": [],     # media\n                    \"t\": \"\",     # creation_timestamp_unix\n                    \"d\": \"\",     # creation_timestamp_readable\n                    \"tt\": \"\",    # title/caption\n                }\n                \n                if self.verbose and index < 3:  # Only show details for first 3 stories\n                    print(f\"\\n   Story #{index+1}:\")\n                    if isinstance(story, dict):\n                        print(f\"   Keys: {', '.join(list(story.keys())[:10])}\")\n                \n                # Extract timestamp\n                timestamp_found = False\n                if isinstance(story, dict):\n                    # Try different possible timestamp fields\n                    timestamp_fields = [\"creation_timestamp\", \"taken_at\", \"timestamp\"]\n                    for field in timestamp_fields:\n                        if field in story and story[field]:\n                            story_entry[\"t\"] = int(story[field])\n                            timestamp_found = True\n                            if self.verbose and index < 3:\n                                print(f\"   Timestamp found in '{field}': {story_entry['t']}\")\n                            break\n                    \n                    # Try media items if no timestamp at story level\n                    if not timestamp_found and \"media\" in story and isinstance(story[\"media\"], list) and len(story[\"media\"]) > 0:\n                        for media_item in story[\"media\"]:\n                            if isinstance(media_item, dict):\n                                for field in timestamp_fields:\n                                    if field in media_item and media_item[field]:\n                                        story_entry[\"t\"] = int(media_item[field])\n                                        timestamp_found = True\n                                        if self.verbose and index < 3:\n                                            print(f\"   Timestamp found in media item '{field}': {story_entry['t']}\")\n                                        break\n                                if timestamp_found:\n                                    break\n                \n                # Format date if timestamp found\n                if story_entry[\"t\"]:\n                    story_entry[\"d\"] = datetime.utcfromtimestamp(\n                        int(story_entry[\"t\"])\n                    ).strftime(\"%B %d, %Y at %I:%M %p\")\n                    if self.verbose and index < 3:\n                        print(f\"   Formatted date: {story_entry['d']}\")\n                \n                # Extract caption/title\n                caption_found = False\n                if isinstance(story, dict):\n                    # Try different possible caption fields\n                    caption_fields = [\"caption\", \"title\", \"text\"]\n                    for field in caption_fields:\n                        if field in story and story[field]:\n                            story_entry[\"tt\"] = story[field]\n                            caption_found = True\n                            if self.verbose and index < 3:\n                                print(f\"   Caption found in '{field}': {story_entry['tt'][:30]}...\")\n                            break\n                    \n                    # Try string_map_data if no caption found directly\n                    if not caption_found and \"string_map_data\" in story and isinstance(story[\"string_map_data\"], dict):\n                        string_map = story[\"string_map_data\"]\n                        caption_keys = [\"Caption\", \"Text\", \"Story Text\"]\n                        for key in caption_keys:\n                            if key in string_map and isinstance(string_map[key], dict) and \"value\" in string_map[key]:\n                                story_entry[\"tt\"] = string_map[key][\"value\"]\n                                caption_found = True\n                                if self.verbose and index < 3:\n                                    print(f\"   Caption found in string_map_data['{key}']: {story_entry['tt'][:30]}...\")\n                                break\n                \n                # Extract media URIs\n                media_found = False\n                if isinstance(story, dict):\n                    # Try direct URI field\n                    if \"uri\" in story and story[\"uri\"]:\n                        story_entry[\"m\"].append(story[\"uri\"])\n                        media_found = True\n                        if self.verbose and index < 3:\n                            print(f\"   Media found directly in 'uri': {story_entry['m'][0]}\")\n                    \n                    # Try media list\n                    if \"media\" in story and isinstance(story[\"media\"], list):\n                        for media_item in story[\"media\"]:\n                            if isinstance(media_item, dict) and \"uri\" in media_item and media_item[\"uri\"]:\n                                story_entry[\"m\"].append(media_item[\"uri\"])\n                                media_found = True\n                                if self.verbose and index < 3 and len(story_entry[\"m\"]) <= 3:\n                                    print(f\"   Media found in media list: {media_item['uri']}\")\n                    \n                    # Try media_map_data\n                    if not media_found and \"media_map_data\" in story and isinstance(story[\"media_map_data\"], dict):\n                        for key, media_item in story[\"media_map_data\"].items():\n                            if isinstance(media_item, dict) and \"uri\" in media_item and media_item[\"uri\"]:\n                                story_entry[\"m\"].append(media_item[\"uri\"])\n                                media_found = True\n                                if self.verbose and index < 3 and len(story_entry[\"m\"]) <= 3:\n                                    print(f\"   Media found in media_map_data['{key}']: {media_item['uri']}\")\n                \n                # Only add stories with valid timestamps and media\n                if story_entry[\"t\"] and story_entry[\"m\"]:\n                    simplified_stories[str(story_entry[\"t\"])] = story_entry\n                    if self.verbose and index < 3:\n                        print(f\"   ✓ Story added with timestamp {story_entry['t']} and {len(story_entry['m'])} media items\")\n                elif self.verbose and index < 3:\n                    if not story_entry[\"t\"]:\n                        print(f\"   ✗ Story skipped: No timestamp found\")\n                    if not story_entry[\"m\"]:\n                        print(f\"   ✗ Story skipped: No media found\")\n            \n            if self.verbose:\n                print(f\"\\n   Extracted {len(simplified_stories)} valid stories from {len(stories_list)} total\")\n            \n            # Sort by timestamp (newest first)\n            sorted_stories = dict(sorted(simplified_stories.items(), key=lambda x: int(x[0]), reverse=True))\n            \n            if self.verbose and sorted_stories:\n                newest = datetime.utcfromtimestamp(int(list(sorted_stories.keys())[0])).strftime('%Y-%m-%d')\n                oldest = datetime.utcfromtimestamp(int(list(sorted_stories.keys())[-1])).strftime('%Y-%m-%d')\n                print(f\"   Stories date range: {oldest} to {newest}\")\n            \n            return sorted_stories\n            \n        except Exception as e:\n            print(f\"Error loading stories data: {str(e)}\")\n            if self.verbose:\n                import traceback\n                traceback.print_exc()\n            return {}\n\n    def load_all_data(self):\n        \"\"\"\n        Load all data and return a comprehensive data package.\n\n        Returns:\n            dict: Data package containing all processed data\n        \"\"\"\n        profile_info = self.load_profile_data()\n        location_info = self.load_location_data()\n        posts_data = self.extract_relevant_data()\n        stories_data = self.load_stories_data()\n        follower_count = self.load_followers_data()\n        \n        # Add follower count to profile info\n        profile_info[\"follower_count\"] = follower_count\n        \n        # Process all string values to fix encoding issues\n        profile_info = self.process_json_strings(profile_info)\n        location_info = self.process_json_strings(location_info)\n        posts_data = self.process_json_strings(posts_data)\n        stories_data = self.process_json_strings(stories_data)\n\n        # Get date range for display\n        if posts_data and isinstance(posts_data, dict) and len(posts_data) > 0:\n            keys = list(posts_data.keys())\n            first_key = keys[0]  # Newest post\n            last_key = keys[-1]  # Oldest post\n\n            # Format timestamps\n            newest_post_date = datetime.utcfromtimestamp(int(first_key)).strftime(\n                \"%B %Y\"\n            )\n            oldest_post_date = datetime.utcfromtimestamp(int(last_key)).strftime(\n                \"%B %Y\"\n            )\n\n            date_range = {\n                \"newest\": newest_post_date,\n                \"oldest\": oldest_post_date,\n                \"range\": f\"{oldest_post_date} - {newest_post_date}\",\n            }\n        else:\n            date_range = {\"newest\": \"Unknown\", \"oldest\": \"Unknown\", \"range\": \"Unknown\"}\n            # If no posts data, create an empty dict to avoid NoneType errors\n            if not isinstance(posts_data, dict):\n                posts_data = {}\n\n        return {\n            \"profile\": profile_info,\n            \"location\": location_info,\n            \"posts\": posts_data,\n            \"stories\": stories_data,\n            \"date_range\": date_range,\n            \"post_count\": len(posts_data),\n            \"story_count\": len(stories_data),\n        }\n"
  },
  {
    "path": "memento_mori/media.py",
    "content": "# memento_mori/media.py\nimport os\nimport shutil\nimport hashlib\nimport base64\nimport re\nimport mimetypes\nimport magic  # python-magic library\nfrom pathlib import Path\nfrom PIL import Image\nfrom concurrent.futures import ThreadPoolExecutor\nimport multiprocessing\nfrom tqdm import tqdm\n\n\nclass InstagramMediaProcessor:\n    \"\"\"\n    Class for processing Instagram media files.\n\n    This class handles:\n    - Converting images to WebP format\n    - Generating thumbnails for images and videos\n    - Copying media files to the output directory\n    \"\"\"\n\n    def __init__(self, extraction_dir, output_dir, thread_count=None, quality=70, max_dimension=1200):\n        \"\"\"Initialize the media processor with paths and options.\"\"\"\n        self.extraction_dir = Path(extraction_dir)\n        self.output_dir = Path(output_dir)\n        self.thread_count = thread_count or max(1, multiprocessing.cpu_count() - 1)\n        self.quality = quality  # Store the quality setting\n        self.max_dimension = max_dimension  # Maximum dimension for resizing\n\n        # Create output directories\n        self.media_dirs = [\n            self.output_dir / \"media\",\n            self.output_dir / \"media\" / \"posts\",\n            self.output_dir / \"media\" / \"other\",\n            self.output_dir / \"thumbnails\",\n        ]\n\n        for directory in self.media_dirs:\n            directory.mkdir(parents=True, exist_ok=True)\n\n        # Statistics\n        self.thumbnail_count = 0\n        self.webp_count = 0\n        self.total_size_original = 0\n        self.total_size_webp = 0\n\n        # Initialize filename mapping\n        self.filename_map = {}\n\n        # Build a basename -> [Path, ...] index for fallback file lookup\n        self.file_index = self._build_file_index()\n\n    def _build_file_index(self):\n        \"\"\"\n        Walk extraction_dir and build a basename -> [Path, ...] index.\n        Warns about any filename collisions found.\n        \"\"\"\n        index = {}\n        for path in self.extraction_dir.rglob(\"*\"):\n            if not path.is_file():\n                continue\n            name = path.name\n            if name not in index:\n                index[name] = []\n            index[name].append(path)\n\n        collisions = {name: paths for name, paths in index.items() if len(paths) > 1}\n        if collisions:\n            print(f\"\\n⚠️  WARNING: {len(collisions)} duplicate filename(s) found across archive\")\n            print(\"   Fallback lookup will use the first match:\")\n            for name, paths in collisions.items():\n                print(f\"   {name} ({len(paths)} copies):\")\n                for p in paths:\n                    print(f\"      {p.relative_to(self.extraction_dir)}\")\n\n        return index\n\n    def shorten_filename(self, original_path):\n        \"\"\"\n        Create a shortened version of a filename while preserving extension.\n        \n        Args:\n            original_path (str): Original file path\n            \n        Returns:\n            str: Shortened file path\n        \"\"\"\n        if not original_path or not isinstance(original_path, str):\n            return original_path\n            \n        # Skip if it's already a data URI\n        if original_path.startswith('data:'):\n            return original_path\n            \n        # Check if we already have a mapping for this path\n        if original_path in self.filename_map:\n            return self.filename_map[original_path]\n            \n        # Parse the path\n        path_obj = Path(original_path)\n        parent_dir = path_obj.parent\n        filename = path_obj.name\n        extension = path_obj.suffix.lower()\n        \n        # Create a hash of the original filename\n        filename_hash = hashlib.md5(filename.encode()).hexdigest()[:8]  # Use first 8 chars of hash\n        \n        # Create new filename: hash + original extension\n        new_filename = f\"{filename_hash}{extension}\"\n        \n        # Create the new path\n        if parent_dir == Path('.'):\n            new_path = new_filename\n        else:\n            new_path = str(parent_dir / new_filename)\n        \n        # Store the mapping\n        self.filename_map[original_path] = new_path\n        \n        return new_path\n\n    def process_media_files(self, post_data, profile_picture, stories_data=None):\n        \"\"\"Process all media files from posts, stories, and profile picture.\"\"\"\n        # First, fix any incorrect file extensions in the extraction directory\n        print(\"Checking and fixing file extensions...\")\n        extension_stats = self.fix_file_extensions(self.extraction_dir)\n        print(f\"Fixed {extension_stats['fixed']} files with incorrect extensions\")\n        \n        # Create a path mapping for quick lookups\n        path_mapping = extension_stats.get(\"path_mapping\", {})\n        \n        # Update profile picture path if it was fixed\n        if profile_picture in path_mapping:\n            profile_picture = path_mapping[profile_picture]\n\n        # Process profile picture and get shortened path (only if profile picture exists)\n        shortened_profile = \"\"\n        if profile_picture and profile_picture.strip():\n            # Check if the profile picture file actually exists\n            profile_path = Path(self.extraction_dir) / profile_picture\n            if profile_path.exists() and profile_path.is_file():\n                shortened_profile = self.shorten_filename(profile_picture)\n                self.copy_file_to_distribution(profile_picture)\n                self.generate_thumbnail(profile_picture, shortened_profile)\n            else:\n                print(f\"Warning: Profile picture not found or is not a file: {profile_picture}\")\n        else:\n            print(\"Warning: No profile picture specified in data\")\n\n        # Collect all media files to process\n        all_media = []\n        story_media = []  # Separate list for story media\n        \n        # Create a deep copy of post_data to modify\n        updated_post_data = {}\n        \n        for timestamp, post in post_data.items():\n            # Create a copy of the post\n            updated_post = post.copy()\n            updated_media = []\n            \n            for media_url in post[\"m\"]:\n                # Check if this media URL was fixed\n                if str(self.extraction_dir / media_url) in path_mapping:\n                    # Get the new path relative to extraction_dir\n                    new_full_path = path_mapping[str(self.extraction_dir / media_url)]\n                    media_url = str(Path(new_full_path).relative_to(self.extraction_dir))\n                \n                # Add to processing list\n                all_media.append(media_url)\n                \n                # Get shortened path\n                shortened_url = self.shorten_filename(media_url)\n                updated_media.append(shortened_url)\n            \n            # Update the post with shortened media URLs\n            updated_post[\"m\"] = updated_media\n            updated_post_data[timestamp] = updated_post\n            \n        # Process stories data if provided\n        updated_stories_data = {}\n        story_thumbnails = {}  # Store story thumbnail paths\n        \n        if stories_data:\n            for timestamp, story in stories_data.items():\n                # Create a copy of the story\n                updated_story = story.copy()\n                updated_media = []\n                \n                for media_url in story[\"m\"]:\n                    # Check if this media URL was fixed\n                    if str(self.extraction_dir / media_url) in path_mapping:\n                        # Get the new path relative to extraction_dir\n                        new_full_path = path_mapping[str(self.extraction_dir / media_url)]\n                        media_url = str(Path(new_full_path).relative_to(self.extraction_dir))\n                    \n                    # Add to processing list\n                    all_media.append(media_url)\n                    story_media.append(media_url)  # Also add to story-specific list\n                    \n                    # Get shortened path\n                    shortened_url = self.shorten_filename(media_url)\n                    updated_media.append(shortened_url)\n                \n                # Update the story with shortened media URLs\n                updated_story[\"m\"] = updated_media\n                updated_stories_data[timestamp] = updated_story\n\n        total_media = len(all_media)\n        print(\n            f\"Processing {total_media} media files using {self.thread_count} threads...\"\n        )\n\n        # Process media files in parallel using ThreadPoolExecutor with tqdm\n        with ThreadPoolExecutor(max_workers=self.thread_count) as executor:\n            list(\n                tqdm(\n                    executor.map(self.copy_file_to_distribution, all_media),\n                    total=total_media,\n                    desc=\"Processing media files\",\n                    unit=\"files\",\n                )\n            )\n        \n        # Generate story thumbnails with 9:16 aspect ratio\n        if story_media:\n            print(f\"Generating {len(story_media)} story thumbnails with 9:16 aspect ratio...\")\n            \n            # Process story thumbnails and collect results\n            with ThreadPoolExecutor(max_workers=self.thread_count) as executor:\n                story_thumb_results = list(\n                    tqdm(\n                        executor.map(\n                            lambda media_url: (\n                                media_url,\n                                self.generate_story_thumbnail(\n                                    self.extraction_dir / media_url, \n                                    self.shorten_filename(media_url)\n                                )\n                            ),\n                            story_media\n                        ),\n                        total=len(story_media),\n                        desc=\"Processing story thumbnails\",\n                        unit=\"files\",\n                    )\n                )\n            \n            # Create a mapping of original media to thumbnail paths\n            for media_url, thumb_path in story_thumb_results:\n                if thumb_path:\n                    # Store relative path from output directory\n                    rel_thumb_path = str(Path(thumb_path).relative_to(self.output_dir))\n                    story_thumbnails[self.shorten_filename(media_url)] = rel_thumb_path\n            \n            # Add thumbnail paths to story data\n            for timestamp, story in updated_stories_data.items():\n                for i, media_url in enumerate(story[\"m\"]):\n                    if media_url in story_thumbnails:\n                        # If this is the first media item, add the thumbnail path to the story\n                        if i == 0:\n                            story[\"story_thumb\"] = story_thumbnails[media_url]\n\n        # Calculate space savings\n        self._calculate_space_savings(post_data)\n\n        # Return updated post data and statistics\n        return {\n            \"updated_post_data\": updated_post_data,\n            \"updated_stories_data\": updated_stories_data,\n            \"shortened_profile\": shortened_profile,\n            \"stats\": {\n                \"thumbnail_count\": self.thumbnail_count,\n                \"webp_count\": self.webp_count,\n                \"total_size_original\": self.total_size_original,\n                \"total_size_webp\": self.total_size_webp,\n                \"space_saved_mb\": (self.total_size_original - self.total_size_webp)\n                / (1024 * 1024),\n                \"percentage_saved\": (\n                    (self.total_size_original - self.total_size_webp)\n                    / self.total_size_original\n                    * 100\n                    if self.total_size_original > 0\n                    else 0\n                ),\n                \"extension_fixes\": extension_stats[\"fixed\"],\n            }\n        }\n\n    def _calculate_space_savings(self, post_data):\n        \"\"\"Calculate space savings from WebP conversion and other optimizations.\"\"\"\n        # Count thumbnails\n        if (self.output_dir / \"thumbnails\").exists():\n            self.thumbnail_count = len(\n                list((self.output_dir / \"thumbnails\").glob(\"*.webp\"))\n            )\n\n        # Calculate total size of original files and their optimized versions\n        self.total_size_original = 0\n        self.total_size_webp = 0\n        \n        # Track all media files that were processed\n        processed_files = set()\n        \n        # First, add all media from posts\n        for timestamp, post in post_data.items():\n            for media_url in post[\"m\"]:\n                processed_files.add(media_url)\n        \n        # Process each file to calculate size differences\n        for media_url in processed_files:\n            # Get the original file path\n            original_path = Path(self.extraction_dir) / media_url\n            \n            # Get the shortened filename\n            shortened_url = self.shorten_filename(media_url)\n            \n            # Check if the original exists\n            if original_path.exists():\n                original_size = original_path.stat().st_size\n                self.total_size_original += original_size\n                \n                # Check for WebP version first\n                webp_path = self.output_dir / (shortened_url.rsplit('.', 1)[0] + '.webp')\n                \n                if webp_path.exists():\n                    # WebP version exists\n                    webp_size = webp_path.stat().st_size\n                    self.total_size_webp += webp_size\n                    self.webp_count += 1\n                else:\n                    # No WebP, check for the original format in output\n                    dest_path = self.output_dir / shortened_url\n                    if dest_path.exists():\n                        dest_size = dest_path.stat().st_size\n                        self.total_size_webp += dest_size\n\n    def copy_file_to_distribution(self, file_path, quiet=True):\n        \"\"\"Copy a file to distribution, optionally converting images to WebP and generating thumbnails.\"\"\"\n        # Skip if it's already a data URI\n        if str(file_path).startswith(\"data:image\"):\n            return True\n\n        source = Path(self.extraction_dir) / file_path\n        \n        # Create shortened filename\n        shortened_path = self.shorten_filename(file_path)\n        destination = Path(self.output_dir) / shortened_path\n\n        # Create directory structure if it doesn't exist\n        destination.parent.mkdir(parents=True, exist_ok=True)\n\n        # Check if source file exists; fall back to basename index if not found\n        if not source.exists():\n            basename = Path(file_path).name\n            candidates = self.file_index.get(basename, [])\n            if candidates:\n                if len(candidates) > 1 and not quiet:\n                    print(f\"Warning: '{basename}' matched {len(candidates)} files in archive\"\n                          f\" — using {candidates[0].relative_to(self.extraction_dir)}\")\n                source = candidates[0]\n            else:\n                if not quiet:\n                    print(f\"Warning: Source file not found anywhere in archive: {file_path}\")\n                return False\n\n        # Check if it's an image or video\n        is_image = bool(re.search(r\"\\.(jpg|jpeg|png|gif)$\", str(file_path), re.I))\n        is_video = bool(re.search(r\"\\.(mp4|mov|avi|webm)$\", str(file_path), re.I))\n\n        if is_image:\n            # Convert image to WebP for better compression\n            webp_destination = Path(\n                str(destination).replace(destination.suffix, \".webp\")\n            )\n            self.convert_to_webp(source, webp_destination, quiet)\n\n            # Generate thumbnail\n            self.generate_thumbnail(source, shortened_path, quiet)\n            return True\n        else:\n            # Copy the file as is (for videos and other file types)\n            shutil.copy2(source, destination)\n\n            # Generate thumbnail for videos\n            if is_video:\n                self.generate_thumbnail(source, shortened_path, quiet)\n            return True\n\n    def convert_to_webp(self, source_path, destination_path, quiet=False):\n        \"\"\"Convert an image to WebP format if it results in a smaller file.\"\"\"\n        try:\n            # Open the image\n            with Image.open(source_path) as img:\n                # Get original dimensions\n                original_width, original_height = img.size\n                \n                # Resize if the image is larger than the maximum dimension\n                if original_width > self.max_dimension or original_height > self.max_dimension:\n                    # Calculate the scaling factor\n                    scale = self.max_dimension / max(original_width, original_height)\n                    new_width = int(original_width * scale)\n                    new_height = int(original_height * scale)\n                    \n                    # Resize the image\n                    img = img.resize((new_width, new_height), Image.LANCZOS)\n                    \n                    if not quiet:\n                        print(f\"Resized image from {original_width}x{original_height} to {new_width}x{new_height}\")\n                \n                # Handle transparency\n                if img.mode in (\"RGBA\", \"LA\") or (\n                    img.mode == \"P\" and \"transparency\" in img.info\n                ):\n                    if img.mode != \"RGBA\":\n                        img = img.convert(\"RGBA\")\n                else:\n                    img = img.convert(\"RGB\")\n\n                # Save as WebP with the configured quality and method=6 for better compression\n                img.save(destination_path, \"WEBP\", quality=self.quality, method=6)\n\n            # Check if the WebP file is actually smaller\n            original_size = source_path.stat().st_size\n            webp_size = destination_path.stat().st_size\n\n            if webp_size > 0 and webp_size < original_size:\n                if not quiet:\n                    print(\n                        f\"Converted to WebP: {source_path} (saved {(original_size - webp_size) / 1024:.2f} KB)\"\n                    )\n                return True\n            else:\n                # If WebP is larger, use the original file\n                if destination_path.exists():\n                    destination_path.unlink()\n\n                # Copy with original extension\n                original_ext = source_path.suffix\n                original_destination = Path(\n                    str(destination_path).replace(\".webp\", original_ext)\n                )\n                shutil.copy2(source_path, original_destination)\n\n                if not quiet:\n                    print(f\"WebP larger than original, using original: {source_path}\")\n                return False\n        except Exception as e:\n            if not quiet:\n                print(f\"Error converting to WebP: {str(e)}\")\n\n            # Fall back to copying the original file\n            original_ext = source_path.suffix\n            original_destination = Path(\n                str(destination_path).replace(\".webp\", original_ext)\n            )\n            original_destination.parent.mkdir(parents=True, exist_ok=True)\n            shutil.copy2(source_path, original_destination)\n            return False\n\n    def fix_file_extensions(self, directory_path):\n        \"\"\"\n        Scan a directory for files with incorrect extensions and fix them.\n        Particularly focuses on media files like HEIC files that are actually JPEGs.\n        \n        Args:\n            directory_path (str or Path): Directory to scan for files with incorrect extensions\n        \n        Returns:\n            dict: Statistics about the fixed files\n        \"\"\"\n        directory_path = Path(directory_path)\n        stats = {\n            \"total_checked\": 0,\n            \"fixed\": 0,\n            \"already_correct\": 0,\n            \"errors\": 0,\n            \"fixed_files\": []\n        }\n        \n        # Make sure we have the mime-type libraries\n        try:\n            # Try the libmagic binding first (common on Linux/Mac)\n            mime = magic.Magic(mime=True)\n        except (TypeError, AttributeError):\n            try:\n                # Try the alternative API (common in some python-magic implementations)\n                mime = magic.open(magic.MAGIC_MIME_TYPE)\n                mime.load()\n            except (AttributeError, TypeError):\n                print(\"Error initializing python-magic. Please install it:\")\n                print(\"pip install python-magic\")\n                if os.name == \"nt\":  # Windows\n                    print(\"Windows users also need to install the binary from: https://github.com/ahupp/python-magic#windows\")\n                return stats\n        \n        # Mapping of MIME types to extensions\n        mime_to_ext = {\n            \"image/jpeg\": \".jpg\",\n            \"image/png\": \".png\",\n            \"image/gif\": \".gif\",\n            \"image/webp\": \".webp\",\n            \"image/heic\": \".heic\",\n            \"video/mp4\": \".mp4\",\n            \"video/quicktime\": \".mov\",\n            \"video/webm\": \".webm\"\n        }\n        \n        # List of media MIME type prefixes we want to process\n        media_mime_prefixes = [\"image/\", \"video/\"]\n        \n        print(f\"Scanning {directory_path} for files with incorrect extensions...\")\n        \n        # Find all files recursively\n        for file_path in tqdm(list(directory_path.glob(\"**/*.*\")), desc=\"Checking files\"):\n            stats[\"total_checked\"] += 1\n            \n            try:\n                # Skip directories\n                if file_path.is_dir():\n                    continue\n                \n                # Skip non-media files based on extension\n                current_ext = file_path.suffix.lower()\n                if current_ext in ['.json', '.txt', '.srt', '.csv', '.html', '.xml', '.md']:\n                    stats[\"already_correct\"] += 1\n                    continue\n                    \n                # Get the current extension and mime type\n                # Handle different magic library interfaces\n                try:\n                    # First approach (libmagic binding)\n                    file_mime = mime.from_file(str(file_path))\n                except AttributeError:\n                    # Second approach (alternative API)\n                    file_mime = mime.file(str(file_path))\n                \n                # Skip if not a media file\n                if not any(file_mime.startswith(prefix) for prefix in media_mime_prefixes):\n                    stats[\"already_correct\"] += 1\n                    continue\n                \n                # Get the correct extension for this mime type\n                correct_ext = mime_to_ext.get(file_mime)\n                \n                if correct_ext is None:\n                    # If we don't have a mapping for this mime type, use mimetypes\n                    correct_ext = mimetypes.guess_extension(file_mime) or current_ext\n                \n                # If extensions don't match, rename the file\n                if correct_ext != current_ext:\n                    new_path = file_path.with_suffix(correct_ext)\n                    \n                    # Avoid overwriting existing files\n                    counter = 1\n                    while new_path.exists():\n                        new_stem = f\"{file_path.stem}_{counter}\"\n                        new_path = file_path.with_stem(new_stem).with_suffix(correct_ext)\n                        counter += 1\n                    \n                    # copy the file with the new extension\n                    # leave the old file in place so if we run the program again, path_mapping is created properly\n                    shutil.copy(file_path, new_path)\n                    \n                    stats[\"fixed\"] += 1\n                    stats[\"fixed_files\"].append({\n                        \"old_path\": str(file_path),\n                        \"new_path\": str(new_path),\n                        \"old_type\": current_ext,\n                        \"new_type\": correct_ext\n                    })\n                    \n                    # Don't print each fixed file to keep output clean\n                else:\n                    stats[\"already_correct\"] += 1\n                    \n            except Exception as e:\n                print(f\"Error processing {file_path}: {str(e)}\")\n                stats[\"errors\"] += 1\n        \n        # Print summary\n        if stats[\"fixed\"] > 0:\n            print(f\"\\n🔧 EXTENSION CORRECTION\")\n            print(f\"   Fixed {stats['fixed']} media files with incorrect extensions\")\n            # Group fixes by type for a cleaner summary\n            fixes_by_type = {}\n            for item in stats[\"fixed_files\"]:\n                key = f\"{item['old_type']} → {item['new_type']}\"\n                if key not in fixes_by_type:\n                    fixes_by_type[key] = 0\n                fixes_by_type[key] += 1\n            \n            # Print summary by type\n            for fix_type, count in fixes_by_type.items():\n                print(f\"   • {count} files: {fix_type}\")\n        else:\n            print(f\"\\n✓ All {stats['already_correct']} media files had correct extensions\")\n        \n        if stats[\"errors\"] > 0:\n            print(f\"   ⚠️ Errors: {stats['errors']}\")\n        \n        # Add a path mapping to the stats\n        stats[\"path_mapping\"] = {item[\"old_path\"]: item[\"new_path\"] for item in stats[\"fixed_files\"]}\n        \n        return stats\n\n    def generate_thumbnail(self, source_path, relative_path, quiet=False):\n        \"\"\"Generate a thumbnail for an image or video file.\"\"\"\n        # Ensure source_path is a Path object\n        source_path = (\n            Path(source_path) if not isinstance(source_path, Path) else source_path\n        )\n\n        # Create thumbnails directory\n        thumbs_dir = self.output_dir / \"thumbnails\"\n        thumbs_dir.mkdir(parents=True, exist_ok=True)\n\n        # Generate unique filename for the thumbnail\n        thumb_filename = hashlib.md5(str(relative_path).encode()).hexdigest() + \".webp\"\n        thumb_path = thumbs_dir / thumb_filename\n\n        # Skip if thumbnail already exists\n        if thumb_path.exists():\n            return thumb_path\n\n        # Target dimensions for square thumbnail\n        target_width = 292\n        target_height = 292\n\n        try:\n            # Check if file exists\n            if not source_path.exists():\n                if not quiet:\n                    print(f\"File not found: {source_path}\")\n                return None\n\n            # Determine if it's a video\n            is_video = bool(re.search(r\"\\.(mp4|mov|avi|webm)$\", str(source_path), re.I))\n\n            if is_video:\n                # Try using OpenCV for video thumbnail\n                try:\n                    import cv2\n\n                    video = cv2.VideoCapture(str(source_path))\n                    if not video.isOpened():\n                        raise Exception(f\"Could not open video: {source_path}\")\n\n                    # Get video properties\n                    total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))\n                    fps = video.get(cv2.CAP_PROP_FPS)\n\n                    # Get frame from 1 second in or middle of video\n                    target_frame = (\n                        min(int(fps), total_frames // 2)\n                        if fps > 0\n                        else total_frames // 2\n                    )\n                    video.set(cv2.CAP_PROP_POS_FRAMES, target_frame)\n\n                    success, frame = video.read()\n                    video.release()\n\n                    if not success:\n                        raise Exception(f\"Failed to extract frame from video\")\n\n                    # Convert to RGB and create PIL image\n                    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)\n                    img = Image.fromarray(frame_rgb)\n\n                except (ImportError, Exception) as e:\n                    if not quiet:\n                        print(f\"Video thumbnail error: {str(e)}\")\n\n                    # Create video placeholder if extraction fails\n                    svg = (\n                        '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"400\" viewBox=\"0 0 400 400\">'\n                        '<rect width=\"400\" height=\"400\" fill=\"#333333\"/>'\n                        '<circle cx=\"200\" cy=\"200\" r=\"60\" fill=\"#ffffff\" fill-opacity=\"0.8\"/>'\n                        '<polygon points=\"180,160 180,240 240,200\" fill=\"#333333\"/>'\n                        \"</svg>\"\n                    )\n\n                    return None\n            else:\n                # For images, use PIL\n                img = Image.open(source_path)\n\n            # Get original dimensions\n            original_width, original_height = img.size\n\n            # Calculate dimensions for cropping to 1:1 aspect ratio (center crop)\n            if original_width > original_height:\n                src_x = (original_width - original_height) // 2\n                src_y = 0\n                src_w = original_height\n                src_h = original_height\n            else:\n                src_x = 0\n                src_y = (original_height - original_width) // 2\n                src_w = original_width\n                src_h = original_width\n\n            # Crop and resize\n            img = img.crop((src_x, src_y, src_x + src_w, src_y + src_h))\n            img = img.resize((target_width, target_height), Image.LANCZOS)\n\n            # Save as WebP\n            img.save(thumb_path, \"WEBP\", quality=80)\n\n            return thumb_path\n\n        except Exception as e:\n            if not quiet:\n                print(f\"Error generating thumbnail: {str(e)}\")\n            return None\n            \n    def generate_story_thumbnail(self, source_path, relative_path, quiet=False):\n        \"\"\"Generate a 9:16 aspect ratio thumbnail for a story.\"\"\"\n        # Ensure source_path is a Path object\n        source_path = Path(source_path) if not isinstance(source_path, Path) else source_path\n\n        # Create thumbnails directory\n        thumbs_dir = self.output_dir / \"thumbnails\" / \"stories\"\n        thumbs_dir.mkdir(parents=True, exist_ok=True)\n\n        # Generate unique filename for the thumbnail\n        thumb_filename = hashlib.md5(str(relative_path).encode()).hexdigest() + \".webp\"\n        thumb_path = thumbs_dir / thumb_filename\n\n        # Skip if thumbnail already exists\n        if thumb_path.exists():\n            return thumb_path\n\n        # Target dimensions for 9:16 aspect ratio thumbnail\n        target_width = 270  # Keeping similar width as square thumbnails\n        target_height = 480  # 9:16 ratio (270 * 16/9)\n\n        try:\n            # Check if file exists\n            if not source_path.exists():\n                if not quiet:\n                    print(f\"File not found: {source_path}\")\n                return None\n\n            # Determine if it's a video\n            is_video = bool(re.search(r\"\\.(mp4|mov|avi|webm)$\", str(source_path), re.I))\n\n            if is_video:\n                # Try using OpenCV for video thumbnail\n                try:\n                    import cv2\n\n                    video = cv2.VideoCapture(str(source_path))\n                    if not video.isOpened():\n                        raise Exception(f\"Could not open video: {source_path}\")\n\n                    # Get video properties\n                    total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))\n                    fps = video.get(cv2.CAP_PROP_FPS)\n\n                    # Get frame from 1 second in or middle of video\n                    target_frame = (\n                        min(int(fps), total_frames // 2)\n                        if fps > 0\n                        else total_frames // 2\n                    )\n                    video.set(cv2.CAP_PROP_POS_FRAMES, target_frame)\n\n                    success, frame = video.read()\n                    video.release()\n\n                    if not success:\n                        raise Exception(f\"Failed to extract frame from video\")\n\n                    # Convert to RGB and create PIL image\n                    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)\n                    img = Image.fromarray(frame_rgb)\n\n                except (ImportError, Exception) as e:\n                    if not quiet:\n                        print(f\"Video thumbnail error: {str(e)}\")\n                    return None\n            else:\n                # For images, use PIL\n                img = Image.open(source_path)\n\n            # Get original dimensions\n            original_width, original_height = img.size\n\n            # Calculate dimensions for cropping to 9:16 aspect ratio (center crop)\n            target_ratio = 9 / 16\n            original_ratio = original_width / original_height\n\n            if original_ratio > target_ratio:  # Image is wider than 9:16\n                # Crop width to match 9:16\n                new_width = int(original_height * target_ratio)\n                src_x = (original_width - new_width) // 2\n                src_y = 0\n                src_w = new_width\n                src_h = original_height\n            else:  # Image is taller than 9:16\n                # Crop height to match 9:16\n                new_height = int(original_width / target_ratio)\n                src_x = 0\n                src_y = (original_height - new_height) // 2\n                src_w = original_width\n                src_h = new_height\n\n            # Crop and resize\n            img = img.crop((src_x, src_y, src_x + src_w, src_y + src_h))\n            img = img.resize((target_width, target_height), Image.LANCZOS)\n\n            # Save as WebP\n            img.save(thumb_path, \"WEBP\", quality=80)\n\n            return thumb_path\n\n        except Exception as e:\n            if not quiet:\n                print(f\"Error generating story thumbnail: {str(e)}\")\n            return None\n"
  },
  {
    "path": "memento_mori/static/css/style.css",
    "content": "/* memento_mori/static/css/style.css */\n:root {\n  --instagram-bg: #fafafa;\n  --instagram-border: #dbdbdb;\n  --instagram-text: #262626;\n  --instagram-link: #0095f6;\n  --header-height: 60px;\n}\n\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nbody {\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n  background-color: var(--instagram-bg);\n  color: var(--instagram-text);\n  line-height: 1.5;\n}\n\nheader {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: var(--header-height);\n  background-color: white;\n  border-bottom: 1px solid var(--instagram-border);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0 20px;\n  z-index: 100;\n}\n\n.header-content {\n  max-width: 975px;\n  width: 100%;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.logo {\n  font-size: 24px;\n  font-weight: bold;\n  color: var(--instagram-text);\n  text-decoration: none;\n}\n\n.date-range-header {\n  color: #8e8e8e;\n  font-size: 14px;\n  margin-left: 15px;\n}\n\nmain {\n  max-width: 975px;\n  margin: calc(var(--header-height) + 30px) auto 30px;\n  padding: 0 20px;\n}\n\n.profile-info {\n  display: flex;\n  align-items: center;\n  margin-bottom: 30px;\n}\n\n.profile-picture {\n  width: 150px;\n  height: 150px;\n  border-radius: 50%;\n  object-fit: cover;\n  margin-right: 30px;\n  background-color: #eee;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 36px;\n  color: #aaa;\n}\n\n.profile-details h1 {\n  font-size: 28px;\n  font-weight: 300;\n  margin-bottom: 15px;\n}\n\n.profile-details .bio {\n  margin: 0 0 10px 0;\n  max-width: 600px;\n  line-height: 1.4;\n}\n\n.profile-details .website {\n  margin: 0 0 15px 0;\n  font-size: 14px;\n}\n\n.profile-details .website a {\n  color: var(--instagram-link);\n  text-decoration: none;\n}\n\n.profile-details .website a:hover {\n  text-decoration: underline;\n}\n\n.stats {\n  display: flex;\n  margin-bottom: 15px;\n  font-size: 16px;\n}\n\n.stat {\n  margin-right: 40px;\n}\n\n.stat-count {\n  font-weight: 600;\n}\n\n.posts-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 28px;\n}\n\n.grid-item {\n  position: relative;\n  aspect-ratio: 1/1;\n  cursor: pointer;\n  overflow: hidden;\n}\n\n.grid-item img,\n.grid-item video {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  transition: transform 0.3s ease;\n  aspect-ratio: 1/1;\n}\n\n.grid-item:hover img,\n.grid-item:hover video {\n  transform: scale(1.05);\n}\n\n.multi-indicator {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  color: white;\n  background-color: rgba(0, 0, 0, 0.6);\n  padding: 3px 8px;\n  border-radius: 4px;\n  font-size: 12px;\n  z-index: 2;\n}\n\n.video-indicator {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  color: white;\n  background-color: rgba(0, 0, 0, 0.7);\n  padding: 4px 10px;\n  border-radius: 4px;\n  font-size: 12px;\n  font-weight: bold;\n  z-index: 2;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n}\n\n.post-modal {\n  display: none;\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(0, 0, 0, 0.9);\n  z-index: 1000;\n  overflow-y: auto;\n}\n\n.post-modal-content {\n  display: flex;\n  max-width: 1200px;\n  margin: 30px auto;\n  background-color: white;\n  height: calc(100vh - 60px);\n  max-height: 800px;\n  border-radius: 4px;\n  overflow: hidden;\n  position: relative;\n}\n\n.post-media {\n  flex: 1;\n  background-color: black;\n  position: relative;\n  min-width: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.post-media img,\n.post-media video {\n  max-width: 100%;\n  max-height: 100%;\n  object-fit: contain;\n}\n\n.post-info {\n  width: 340px;\n  border-left: 1px solid var(--instagram-border);\n  display: flex;\n  flex-direction: column;\n}\n\n.post-header {\n  padding: 16px;\n  border-bottom: 1px solid var(--instagram-border);\n  display: flex;\n  align-items: center;\n}\n\n.post-user {\n  width: 32px;\n  height: 32px;\n  border-radius: 50%;\n  margin-right: 12px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background-color: #eee;\n  font-size: 14px;\n  color: #aaa;\n}\n\n.post-username {\n  font-weight: 600;\n  flex-grow: 1;\n}\n\n.share-button {\n  cursor: pointer;\n  padding: 5px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: background-color 0.2s;\n}\n\n.share-button:hover {\n  background-color: rgba(0, 0, 0, 0.1);\n}\n\n.share-button svg {\n  width: 18px;\n  height: 18px;\n  color: #8e8e8e;\n}\n\n.post-caption {\n  padding: 16px;\n  flex-grow: 1;\n  overflow-y: auto;\n}\n\n.post-date {\n  padding: 16px;\n  color: #8e8e8e;\n  font-size: 12px;\n  border-top: 1px solid var(--instagram-border);\n}\n\n.post-stats {\n  padding: 12px 16px;\n  color: var(--instagram-text);\n  font-size: 14px;\n  border-top: 1px solid var(--instagram-border);\n  display: flex;\n  gap: 16px;\n}\n\n.post-stat {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.post-stat-icon {\n  font-size: 16px;\n}\n\n.likes-indicator {\n  position: absolute;\n  bottom: 10px;\n  left: 10px;\n  color: white;\n  background-color: rgba(0, 0, 0, 0.7);\n  padding: 4px 10px;\n  border-radius: 4px;\n  font-size: 12px;\n  font-weight: bold;\n  z-index: 2;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n}\n\n.close-modal {\n  position: absolute;\n  top: 20px;\n  right: 20px;\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 1001;\n}\n\n.modal-nav {\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 1001;\n  background-color: rgba(0, 0, 0, 0.5);\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.modal-prev {\n  left: 20px;\n}\n\n.modal-next {\n  right: 20px;\n}\n\n.slideshow-nav {\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 5;\n  background-color: rgba(0, 0, 0, 0.7);\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: background-color 0.2s;\n}\n\n.slideshow-nav:hover {\n  background-color: rgba(0, 0, 0, 0.9);\n}\n\n.slideshow-prev {\n  left: 10px;\n}\n\n.slideshow-next {\n  right: 10px;\n}\n\n.slideshow-indicator {\n  position: absolute;\n  bottom: 20px;\n  left: 0;\n  right: 0;\n  display: flex;\n  justify-content: center;\n  z-index: 5;\n  background-color: rgba(0, 0, 0, 0.3);\n  padding: 8px 0;\n  border-radius: 20px;\n  width: auto;\n  max-width: 80%;\n  margin: 0 auto;\n}\n\n.slideshow-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background-color: rgba(255, 255, 255, 0.5);\n  margin: 0 4px;\n  cursor: pointer;\n  transition: background-color 0.2s;\n}\n\n.slideshow-dot:hover {\n  background-color: rgba(255, 255, 255, 0.8);\n}\n\n.slideshow-dot.active {\n  background-color: white;\n}\n\n.media-container {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.media-slide {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  opacity: 0;\n  transition: opacity 0.3s ease, transform 0.5s ease;\n  pointer-events: none;\n  will-change: transform, opacity;\n  transform: translateX(0);\n}\n\n.media-slide img,\n.media-slide video {\n  max-width: 100%;\n  max-height: 100%;\n  object-fit: contain;\n}\n\n.media-slide.active {\n  opacity: 1;\n  z-index: 2;\n  pointer-events: auto;\n}\n\n.media-slide.active {\n  opacity: 1;\n  z-index: 2;\n}\n\n.file-input-container {\n  margin-bottom: 20px;\n  padding: 20px;\n  background-color: white;\n  border: 1px solid var(--instagram-border);\n  border-radius: 4px;\n}\n\n.loading {\n  text-align: center;\n  padding: 40px;\n  font-size: 18px;\n}\n\n.sort-options {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 10px 20px;\n  margin-bottom: 20px;\n}\n\n.sort-row {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-wrap: wrap;\n  margin: 5px 0;\n  width: 100%;\n  max-width: 600px;\n}\n\n.sort-link {\n  margin: 0 10px;\n  color: var(--instagram-text);\n  text-decoration: none;\n  padding: 5px 0;\n  position: relative;\n  transition: color 0.2s;\n}\n\n.sort-link:hover {\n  color: var(--instagram-link);\n}\n\n.sort-link.active {\n  color: var(--instagram-link);\n  font-weight: 600;\n}\n\n.sort-link.active::after {\n  content: '';\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  width: 100%;\n  height: 2px;\n  background-color: var(--instagram-link);\n}\n\n@media (max-width: 768px) {\n  .posts-grid {\n    grid-template-columns: repeat(2, 1fr);\n    gap: 4px;\n  }\n\n  .post-modal-content {\n    flex-direction: column;\n    height: auto;\n    max-height: none;\n    margin: 30px auto 0;\n    border-radius: 0;\n    width: 100%;\n  }\n\n  .post-media {\n    height: 50vh;\n    width: 100%;\n    min-height: 300px;\n    position: relative;\n  }\n\n  .post-info {\n    width: 100%;\n    border-left: none;\n    border-top: 1px solid var(--instagram-border);\n  }\n\n  .profile-picture {\n    width: 80px;\n    height: 80px;\n    margin-right: 15px;\n  }\n\n  .stat {\n    margin-right: 20px;\n  }\n\n  .post-modal {\n    overflow-y: auto;\n    padding-top: 0;\n  }\n\n  .media-container {\n    position: relative;\n    width: 100%;\n    height: 100%;\n  }\n\n  .media-slide {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n\n  .media-slide img,\n  .media-slide video {\n    max-width: 100%;\n    max-height: 100%;\n    object-fit: contain;\n  }\n}\n\n@media (max-width: 480px) {\n  .posts-grid {\n    grid-template-columns: repeat(3, 1fr);\n    gap: 3px;\n  }\n\n  .profile-info {\n    flex-direction: column;\n    text-align: center;\n  }\n\n  .profile-picture {\n    margin-right: 0;\n    margin-bottom: 15px;\n  }\n\n  .stats {\n    justify-content: center;\n  }\n\n  .header-content {\n    flex-direction: column;\n    align-items: center;\n    padding: 5px 0;\n  }\n\n  .date-range-header {\n    margin-left: 0;\n    margin-top: 2px;\n    font-size: 12px;\n  }\n\n  .sort-options {\n    padding: 5px;\n  }\n\n  .sort-row {\n    width: 100%;\n    flex-wrap: wrap;\n    justify-content: center;\n  }\n\n  .sort-link {\n    margin: 5px;\n    font-size: 13px;\n    padding: 5px 0;\n    flex: 0 0 auto;\n  }\n}\n\n/* Stories grid styles */\n.stories-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n  gap: 20px;\n  margin-top: 20px;\n}\n\n.story-item {\n  position: relative;\n  border-radius: 8px;\n  overflow: hidden;\n  background-color: #fff;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n  transition: transform 0.2s ease;\n}\n\n.story-item:hover {\n  transform: translateY(-5px);\n}\n\n.story-info {\n  padding: 12px;\n}\n\n.story-date {\n  font-size: 12px;\n  color: #8e8e8e;\n  margin-bottom: 4px;\n}\n\n/* Story caption styles removed as they're now used for alt text */\n\n.video-indicator {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  width: 30px;\n  height: 30px;\n  background-color: rgba(0, 0, 0, 0.5);\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.video-placeholder {\n  width: 100%;\n  height: 300px;\n  background-color: #f0f0f0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n/* Navigation links */\n.nav-link {\n  margin-right: 20px;\n  font-weight: 600;\n  color: #8e8e8e;\n  text-decoration: none;\n}\n\n.nav-link.active {\n  color: #262626;\n  border-bottom: 2px solid #262626;\n}\n\n/* Mobile-specific fixes */\n@media (max-width: 768px) {\n\n  /* Ensure the modal takes up the full screen */\n  .post-modal {\n    padding: 0;\n    overflow-y: auto;\n  }\n\n  /* Make modal content take full width */\n  .post-modal-content {\n    flex-direction: column;\n    height: auto;\n    margin: 0;\n    width: 100%;\n    max-width: 100%;\n  }\n\n  /* Explicitly set post-media height */\n  .post-media {\n    height: 50vh !important;\n    /* Important to override any inline styles */\n    min-height: 300px !important;\n    width: 100%;\n    flex: 0 0 auto;\n    /* Don't grow or shrink */\n  }\n\n  /* Ensure media container fills the available space */\n  .media-container {\n    position: relative;\n    width: 100%;\n    height: 100% !important;\n    display: flex !important;\n    align-items: center;\n    justify-content: center;\n  }\n\n  /* Fix media slides */\n  .media-slide {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    display: flex !important;\n    align-items: center;\n    justify-content: center;\n  }\n\n  /* Ensure images don't exceed container */\n  .media-slide img,\n  .media-slide video {\n    max-width: 100%;\n    max-height: 100%;\n    width: auto;\n    height: auto;\n    object-fit: contain;\n  }\n\n  /* Make post info section scroll independently if needed */\n  .post-info {\n    flex: 1 1 auto;\n    overflow-y: auto;\n    max-height: 50vh;\n  }\n}\n\n/* Stories section */\n.section-title {\n    text-align: center;\n    margin: 30px 0 15px;\n}\n\n.section-title h2 {\n    font-size: 24px;\n    font-weight: 600;\n    color: #262626;\n    margin: 0;\n    padding-bottom: 10px;\n    border-bottom: 1px solid #dbdbdb;\n}\n\n.story-item {\n    position: relative;\n}\n\n.story-info {\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    background: rgba(0, 0, 0, 0.5);\n    color: white;\n    padding: 8px;\n    font-size: 12px;\n    opacity: 0;\n    transition: opacity 0.3s ease;\n}\n\n.story-item:hover .story-info {\n    opacity: 1;\n}\n\n.story-date {\n    font-weight: bold;\n    margin-bottom: 4px;\n    color: white;\n    opacity: 1;\n}\n\n.story-caption {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    display: -webkit-box;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\n/* Stories page specific styles */\n.stories-container {\n  margin-top: 30px;\n}\n\n.stories-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n  gap: 20px;\n  margin-top: 20px;\n}\n\n.story-item {\n  border-radius: 8px;\n  overflow: hidden;\n  background-color: #fff;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n  transition: transform 0.2s ease;\n  cursor: pointer;\n}\n\n.story-item:hover {\n  transform: translateY(-5px);\n}\n\n.story-media {\n  position: relative;\n  aspect-ratio: 9/16;\n  overflow: hidden;\n  background-color: #000;\n}\n\n.story-media img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n}\n\n.story-viewer {\n  display: none;\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100vh; /* Explicitly use viewport height */\n  background-color: rgba(0, 0, 0, 0.95);\n  z-index: 2000;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n}\n\n.story-close {\n  position: absolute;\n  top: 20px;\n  right: 20px;\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 2001;\n  width: 40px;\n  height: 40px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background-color: rgba(0, 0, 0, 0.5);\n  border-radius: 50%;\n}\n\n.story-nav {\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  color: white;\n  font-size: 30px;\n  cursor: pointer;\n  z-index: 2001;\n  background-color: rgba(0, 0, 0, 0.5);\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.story-prev {\n  left: 20px;\n}\n\n.story-next {\n  right: 20px;\n}\n\n.story-progress-container {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 4px;\n  background-color: rgba(255, 255, 255, 0.3);\n  z-index: 2001;\n}\n\n.story-progress {\n  height: 100%;\n  width: 0%;\n  background-color: white;\n  transition: width 10s linear;\n}\n\n.story-content {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\n.story-media-container {\n  max-width: 100%;\n  max-height: 100%;\n  width: 100%;\n  height: 100%;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  position: relative;\n  overflow: hidden; /* Important to hide slides that move outside the container */\n}\n\n.story-media-container img,\n.story-media-container video {\n  width: 100%;\n  height: 100%; \n  max-height: 90vh;\n  object-fit: contain;\n}\n\n.story-info-overlay {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  width: 100%;\n  padding: 20px;\n  background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));\n  color: white;\n}\n\n/* Caption styles removed as they're now used for alt text */\n.story-info-overlay .story-date {\n  font-size: 14px;\n  opacity: 1;\n  color: white;\n  max-width: 800px;\n  margin: 0 auto;\n  text-align: center;\n}\n\n@media (max-width: 768px) {\n  .story-media-container img,\n  .story-media-container video {\n    max-height: 80vh;\n  }\n  \n  .story-nav {\n    width: 30px;\n    height: 30px;\n    font-size: 20px;\n  }\n  \n  .story-close {\n    width: 30px;\n    height: 30px;\n    font-size: 20px;\n  }\n  \n  .story-info-overlay {\n    padding: 15px;\n  }\n  \n  .story-info-overlay .story-caption {\n    font-size: 14px;\n  }\n  \n  .story-info-overlay .story-date {\n    font-size: 12px;\n  }\n}\n\n/* Navigation links */\n.nav-link {\n  color: #0095f6;\n  text-decoration: none;\n  padding-bottom: 2px;\n  transition: color 0.2s ease;\n  position: relative;\n}\n\n.nav-link:hover {\n  color: #00376b;\n  text-decoration: underline;\n}\n\n.nav-link.active {\n  font-weight: 600;\n  color: #262626;\n  border-bottom: 2px solid #262626;\n}\n"
  },
  {
    "path": "memento_mori/static/js/modal.js",
    "content": "// memento_mori/static/js/modal.js\ndocument.addEventListener('DOMContentLoaded', function () {\n    // Get DOM elements\n    const postsGrid = document.getElementById('postsGrid');\n    const postModal = document.getElementById('postModal');\n    const closeModalBtn = document.getElementById('closeModal');\n    const modalPrev = document.getElementById('modalPrev');\n    const modalNext = document.getElementById('modalNext');\n    const postMedia = document.getElementById('postMedia');\n    const postCaption = document.getElementById('postCaption');\n    const postStats = document.getElementById('postStats');\n    const postDate = document.getElementById('postDate');\n    const postUsername = document.getElementById('postUsername');\n    const postUserPic = document.getElementById('postUserPic');\n    const sortLinks = document.querySelectorAll('.sort-link');\n\n    // Global variables to track current post and indexes\n    let currentPostIndex = -1;\n    let currentSlideIndex = 0;\n    let postIndexToTimestamp = {}; // Map post index to timestamp\n    let currentSortType = 'newest'; // Default sort\n\n    // Initialize by creating mapping and attaching listeners\n    function initialize() {\n        // Create a mapping from post index to timestamp\n        Object.entries(window.postData).forEach(([timestamp, post]) => {\n            postIndexToTimestamp[post.i] = timestamp;\n        });\n\n        // Attach click listeners to grid items\n        attachGridItemListeners();\n\n        // Initialize sorting functionality\n        initializeSorting();\n    }\n\n    // Initialize sorting functionality\n    function initializeSorting() {\n        // Add event listeners to sort links\n        sortLinks.forEach(link => {\n            link.addEventListener('click', function (e) {\n                e.preventDefault();\n\n                // Update active class\n                sortLinks.forEach(l => l.classList.remove('active'));\n                this.classList.add('active');\n\n                // Get sort type and sort posts\n                const sortType = this.getAttribute('data-sort');\n                currentSortType = sortType;\n                sortPosts(sortType);\n            });\n        });\n    }\n\n    // Sort posts based on selected criteria\n    function sortPosts(sortType) {\n        // Get all grid items\n        let gridItems = Array.from(document.querySelectorAll('.grid-item'));\n\n        // Sort the grid items based on the selected criteria\n        switch (sortType) {\n            case 'newest':\n                // Sort by timestamp (newest first) - this is the default\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const timestampA = getTimestampByIndex(indexA);\n                    const timestampB = getTimestampByIndex(indexB);\n                    return timestampB - timestampA;\n                });\n                break;\n\n            case 'oldest':\n                // Sort by timestamp (oldest first)\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const timestampA = getTimestampByIndex(indexA);\n                    const timestampB = getTimestampByIndex(indexB);\n                    return timestampA - timestampB;\n                });\n                break;\n\n            case 'most-likes':\n                // Sort by number of likes\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const likesA = getLikesByIndex(indexA) || 0;\n                    const likesB = getLikesByIndex(indexB) || 0;\n                    return likesB - likesA;\n                });\n                break;\n\n            case 'most-comments':\n                // Sort by number of comments\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const commentsA = getCommentsByIndex(indexA) || 0;\n                    const commentsB = getCommentsByIndex(indexB) || 0;\n                    return commentsB - commentsA;\n                });\n                break;\n\n            case 'most-views':\n                // Sort by number of views/impressions\n                gridItems.sort((a, b) => {\n                    const indexA = parseInt(a.getAttribute('data-index'));\n                    const indexB = parseInt(b.getAttribute('data-index'));\n                    const viewsA = getViewsByIndex(indexA) || 0;\n                    const viewsB = getViewsByIndex(indexB) || 0;\n                    return viewsB - viewsA;\n                });\n                break;\n\n            case 'random':\n                // Shuffle the grid items randomly\n                gridItems.sort(() => Math.random() - 0.5);\n                break;\n        }\n\n        // Reorder the grid items in the DOM\n        const fragment = document.createDocumentFragment();\n        gridItems.forEach(item => {\n            fragment.appendChild(item);\n        });\n\n        // Clear the grid and append the sorted items\n        postsGrid.innerHTML = '';\n        postsGrid.appendChild(fragment);\n\n        // Reattach event listeners to grid items\n        attachGridItemListeners();\n    }\n\n    // Helper function to get timestamp by post index\n    function getTimestampByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        return parseInt(timestamp);\n    }\n\n    // Helper function to get likes by post index\n    function getLikesByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        if (timestamp && window.postData[timestamp]) {\n            return parseInt(window.postData[timestamp].l) || 0;\n        }\n        return 0;\n    }\n\n    // Helper function to get comments by post index\n    function getCommentsByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        if (timestamp && window.postData[timestamp]) {\n            return parseInt(window.postData[timestamp].c) || 0;\n        }\n        return 0;\n    }\n\n    // Helper function to get views/impressions by post index\n    function getViewsByIndex(index) {\n        const timestamp = postIndexToTimestamp[index];\n        if (timestamp && window.postData[timestamp]) {\n            return parseInt(window.postData[timestamp].im) || 0;\n        }\n        return 0;\n    }\n\n    // Attach click event listeners to all grid items\n    function attachGridItemListeners() {\n        const gridItems = document.querySelectorAll('.grid-item');\n        gridItems.forEach(item => {\n            item.addEventListener('click', function () {\n                const postIndex = parseInt(this.getAttribute('data-index'));\n                openModal(postIndex);\n            });\n        });\n    }\n\n    // Open the modal with the selected post\n    function openModal(index, imageIndex = 0) {\n        currentPostIndex = index;\n\n        // Store the current scroll position before opening the modal\n        const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;\n\n        // Get the timestamp using the post_index mapping\n        const timestamp = postIndexToTimestamp[index];\n\n        // Get the post data using the timestamp\n        const post = window.postData[timestamp];\n\n        // Show the modal first (important for correct dimensions)\n        postModal.style.display = 'block';\n        document.body.style.overflow = 'hidden'; // Prevent scrolling\n\n        // Store the scroll position as a data attribute on the modal\n        postModal.setAttribute('data-scroll-position', scrollPosition);\n\n        // Update modal content\n        updateModalContent(post, imageIndex);\n\n        // Update URL with post ID and image index\n        updateUrlWithPostInfo(timestamp, imageIndex);\n\n        // For mobile devices, ensure content is visible and properly sized\n        if (window.innerWidth <= 768) {\n            // Don't scroll to top on mobile as it causes the issue\n            // Instead, just ensure the modal is properly positioned\n            postModal.scrollTop = 0;\n\n            // Force layout recalculation with a longer timeout\n            setTimeout(() => {\n                const mediaContainer = document.querySelector('.media-container');\n                const postMediaEl = document.getElementById('postMedia');\n\n                // Ensure post-media has explicit height\n                if (postMediaEl) {\n                    postMediaEl.style.height = '50vh';\n                    postMediaEl.style.minHeight = '300px';\n                }\n\n                // Ensure media-container has explicit height\n                if (mediaContainer) {\n                    mediaContainer.style.height = '100%';\n                    mediaContainer.style.display = 'flex';\n\n                    // Force reflow\n                    void mediaContainer.offsetHeight;\n                }\n\n                // Reset any active slides to ensure they're visible\n                const activeSlides = document.querySelectorAll('.media-slide.active');\n                activeSlides.forEach(slide => {\n                    slide.style.opacity = '0';\n                    void slide.offsetHeight; // Force reflow\n                    slide.style.opacity = '1';\n\n                    // Make sure images have height\n                    const img = slide.querySelector('img');\n                    if (img) {\n                        img.style.maxHeight = '100%';\n                        img.style.width = 'auto';\n                        img.style.height = 'auto';\n                    }\n                });\n            }, 50); // Increase timeout for more reliability\n        }\n    }\n\n    // Function to update the URL with post and image information\n    function updateUrlWithPostInfo(timestamp, imageIndex) {\n        // Create a new URL object based on the current URL\n        const url = new URL(window.location.href);\n\n        // Set the post parameter to the timestamp\n        url.searchParams.set('post', timestamp);\n\n        // Only add the image parameter if it's not the first image\n        if (imageIndex > 0) {\n            url.searchParams.set('image', imageIndex);\n        } else {\n            url.searchParams.delete('image');\n        }\n\n        // Update the browser history without reloading the page\n        window.history.pushState({}, '', url);\n    }\n    // Creates the appropriate media element (video or image) based on the file type\n    function createMediaElement(mediaUrl) {\n        // Check if the media is a video based on file extension\n        if (mediaUrl.endsWith('.mp4') || mediaUrl.endsWith('.mov') ||\n            mediaUrl.endsWith('.avi') || mediaUrl.endsWith('.webm')) {\n\n            // Create video element\n            const video = document.createElement('video');\n            video.src = mediaUrl;\n            video.controls = true;\n            video.autoplay = true;\n            video.loop = true;\n            video.muted = false;\n            video.playsInline = true;\n            video.alt = 'Instagram video post';\n\n            return video;\n        } else {\n            // Create image element\n            const img = document.createElement('img');\n\n            // Check if there's a WebP version available for non-WebP images\n            if (!mediaUrl.endsWith('.webp') &&\n                (mediaUrl.endsWith('.jpg') || mediaUrl.endsWith('.jpeg') ||\n                    mediaUrl.endsWith('.png') || mediaUrl.endsWith('.gif'))) {\n\n                // Try to use WebP version if it exists\n                const webpUrl = mediaUrl.replace(/\\.(jpg|jpeg|png|gif)$/i, '.webp');\n\n                // Set up error handling to fall back to original if WebP doesn't exist\n                img.onerror = function () {\n                    this.onerror = null; // Prevent infinite loop\n                    this.src = mediaUrl; // Fall back to original\n                };\n\n                img.src = webpUrl;\n            } else {\n                img.src = mediaUrl;\n            }\n\n            img.alt = 'Instagram post';\n\n            return img;\n        }\n    }\n    // Update modal content with the post data\n    function updateModalContent(post, initialImageIndex = 0) {\n        // Clear previous content\n        postMedia.innerHTML = '';\n        postCaption.innerHTML = '';\n        postStats.innerHTML = '';\n\n        // Create media container for the slides\n        const mediaContainer = document.createElement('div');\n        mediaContainer.className = 'media-container';\n\n        // Check if the post has multiple media\n        if (post.m && post.m.length > 1) {  // Changed from media\n            // Create slides for each media item\n            post.m.forEach((mediaUrl, index) => {  // Changed from media\n                const slide = document.createElement('div');\n                slide.className = `media-slide ${index === initialImageIndex ? 'active' : ''}`;\n\n                // Create and add the appropriate media element\n                const mediaElement = createMediaElement(mediaUrl);\n                slide.appendChild(mediaElement);\n\n                mediaContainer.appendChild(slide);\n            });\n\n            // Add navigation buttons for slideshow\n            const prevBtn = document.createElement('div');\n            prevBtn.className = 'slideshow-nav slideshow-prev';\n            prevBtn.innerHTML = '❮';\n            prevBtn.addEventListener('click', function (e) {\n                e.stopPropagation();\n                navigateSlideshow(-1);\n            });\n\n            const nextBtn = document.createElement('div');\n            nextBtn.className = 'slideshow-nav slideshow-next';\n            nextBtn.innerHTML = '❯';\n            nextBtn.addEventListener('click', function (e) {\n                e.stopPropagation();\n                navigateSlideshow(1);\n            });\n\n            // Add indicator dots\n            const indicator = document.createElement('div');\n            indicator.className = 'slideshow-indicator';\n\n            for (let i = 0; i < post.m.length; i++) {\n                const dot = document.createElement('div');\n                dot.className = `slideshow-dot ${i === initialImageIndex ? 'active' : ''}`;\n                dot.setAttribute('data-index', i);\n                dot.addEventListener('click', function (e) {\n                    e.stopPropagation();\n                    const index = parseInt(this.getAttribute('data-index'));\n                    showSlide(index);\n                });\n                indicator.appendChild(dot);\n            }\n\n            mediaContainer.appendChild(prevBtn);\n            mediaContainer.appendChild(nextBtn);\n            mediaContainer.appendChild(indicator);\n\n            // Set the current slide index to the initial image index\n            currentSlideIndex = initialImageIndex;\n        } else {\n            // Single media post\n            const slide = document.createElement('div');\n            slide.className = 'media-slide active';\n\n            // Create and add the appropriate media element\n            const mediaElement = createMediaElement(post.m[0]);  // Changed from media\n            slide.appendChild(mediaElement);\n\n            mediaContainer.appendChild(slide);\n        }\n\n        postMedia.appendChild(mediaContainer);\n\n        // Set post caption\n        if (post.tt) {\n            postCaption.innerHTML = post.tt.replace(/\\n/g, '<br>');\n        } else {\n            postCaption.innerHTML = '';\n        }\n\n        // Set post stats\n        if (post.im) {\n            const impressionsDiv = document.createElement('div');\n            impressionsDiv.className = 'post-stat';\n            impressionsDiv.innerHTML = `\n                <span class=\"post-stat-icon\">👁️</span>\n                <span>${post.im} views</span>\n            `;\n            postStats.appendChild(impressionsDiv);\n        }\n\n        if (post.l) {\n            const likesDiv = document.createElement('div');\n            likesDiv.className = 'post-stat';\n            likesDiv.innerHTML = `\n                <span class=\"post-stat-icon\">♥</span>\n                <span>${post.l}</span>\n            `;\n            postStats.appendChild(likesDiv);\n        }\n\n        if (post.c) {\n            const commentsDiv = document.createElement('div');\n            commentsDiv.className = 'post-stat';\n            commentsDiv.innerHTML = `\n                <span class=\"post-stat-icon\">💬</span>\n                <span>${post.c} comments</span>\n            `;\n            postStats.appendChild(commentsDiv);\n        }\n\n        // Set post date\n        postDate.textContent = post.d;\n\n        // Show/hide stats container based on whether there are any stats\n        postStats.style.display = postStats.children.length > 0 ? 'flex' : 'none';\n    }\n\n    // Navigate between slides in a multi-media post\n    function navigateSlideshow(direction) {\n        const slides = document.querySelectorAll('.media-slide');\n        const dots = document.querySelectorAll('.slideshow-dot');\n        let activeIndex = 0;\n\n        // Find the currently active slide\n        slides.forEach((slide, index) => {\n            if (slide.classList.contains('active')) {\n                activeIndex = index;\n            }\n        });\n\n        // Pause any videos in the current slide\n        const currentVideo = slides[activeIndex].querySelector('video');\n        if (currentVideo) {\n            currentVideo.pause();\n        }\n\n        // Calculate the new index\n        let newIndex = activeIndex + direction;\n        if (newIndex < 0) newIndex = slides.length - 1;\n        if (newIndex >= slides.length) newIndex = 0;\n\n        // Update slides and dots\n        showSlide(newIndex);\n    }\n\n    // Show a specific slide\n    function showSlide(index) {\n        const slides = document.querySelectorAll('.media-slide');\n        const dots = document.querySelectorAll('.slideshow-dot');\n\n        // Pause all videos before changing slides\n        slides.forEach(slide => {\n            const video = slide.querySelector('video');\n            if (video) {\n                video.pause();\n            }\n        });\n\n        // Remove active class from all slides and dots\n        slides.forEach(slide => slide.classList.remove('active'));\n        if (dots.length > 0) {\n            dots.forEach(dot => dot.classList.remove('active'));\n            dots[index].classList.add('active');\n        }\n\n        // Add active class to the selected slide\n        slides[index].classList.add('active');\n\n        // Update current slide index\n        currentSlideIndex = index;\n\n        // Update URL with the new image index\n        const timestamp = postIndexToTimestamp[currentPostIndex];\n        updateUrlWithPostInfo(timestamp, index);\n    }\n\n    // Navigate between posts (next/prev buttons in modal)\n    function navigatePost(direction) {\n        // Pause all videos in the current post\n        const videos = document.querySelectorAll('.media-slide video');\n        videos.forEach(video => {\n            if (video) {\n                video.pause();\n            }\n        });\n\n        // Get all grid items in their current sorted order\n        const gridItems = Array.from(document.querySelectorAll('.grid-item'));\n        const gridIndexes = gridItems.map(item => parseInt(item.getAttribute('data-index')));\n\n        // Find the position of the current post in the sorted grid\n        const currentPosition = gridIndexes.indexOf(currentPostIndex);\n\n        if (currentPosition === -1) {\n            console.error('Current post not found in grid');\n            return;\n        }\n\n        // Calculate new position with wraparound\n        let newPosition = (currentPosition + direction + gridIndexes.length) % gridIndexes.length;\n\n        // Get the new post index from the grid's current order\n        const newPostIndex = gridIndexes[newPosition];\n\n        // Open the new post\n        openModal(newPostIndex);\n    }\n\n    // Close the modal\n    function closeModal() {\n        // Pause all videos before closing the modal\n        const videos = document.querySelectorAll('.media-slide video');\n        videos.forEach(video => {\n            if (video) {\n                video.pause();\n            }\n        });\n\n        // Store the current scroll position before closing the modal\n        const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;\n\n        postModal.style.display = 'none';\n        document.body.style.overflow = 'auto'; // Re-enable scrolling\n\n        // Remove post and image parameters from URL\n        const url = new URL(window.location.href);\n        url.searchParams.delete('post');\n        url.searchParams.delete('image');\n        window.history.pushState({}, '', url);\n\n        // Restore the scroll position after a short delay\n        setTimeout(() => {\n            window.scrollTo({\n                top: scrollPosition,\n                behavior: 'auto' // Use 'auto' instead of 'smooth' to prevent visible scrolling\n            });\n        }, 10);\n    }\n\n    // Event listeners for modal navigation\n    closeModalBtn.addEventListener('click', closeModal);\n    modalPrev.addEventListener('click', function (e) {\n        e.stopPropagation();\n        navigatePost(-1);\n    });\n    modalNext.addEventListener('click', function (e) {\n        e.stopPropagation();\n        navigatePost(1);\n    });\n\n    // Close modal when clicking outside of content\n    postModal.addEventListener('click', function (e) {\n        if (e.target === postModal) {\n            closeModal();\n        }\n    });\n\n    // Keyboard navigation\n    document.addEventListener('keydown', function (e) {\n        if (postModal.style.display === 'block') {\n            if (e.key === 'Escape') {\n                closeModal();\n            } else if (e.key === 'ArrowLeft') {\n                navigatePost(-1);\n            } else if (e.key === 'ArrowRight') {\n                navigatePost(1);\n            }\n        }\n    });\n\n    // Initialize the modal functionality\n    if (typeof window.postData !== 'undefined') {\n        initialize();\n\n        // Check if URL has post and image parameters\n        const urlParams = new URLSearchParams(window.location.search);\n        const postTimestamp = urlParams.get('post');\n        const imageIndex = parseInt(urlParams.get('image') || '0');\n\n        if (postTimestamp && window.postData[postTimestamp]) {\n            // Find the post index from the timestamp\n            let postIndex = -1;\n            Object.entries(postIndexToTimestamp).forEach(([index, timestamp]) => {\n                if (timestamp === postTimestamp) {\n                    postIndex = parseInt(index);\n                }\n            });\n\n            if (postIndex >= 0) {\n                // Open the modal with the specified post and image\n                setTimeout(() => {\n                    openModal(postIndex, imageIndex);\n                }, 500); // Delay to ensure everything is loaded\n            }\n        }\n    } else {\n        console.error('Post data not available');\n    }\n});\n\n\n\n\n\n/**\n * Fixes common Unicode encoding issues in text\n * @param {string} text - The text to fix\n * @return {string} - The fixed text\n */\nfunction fixEncodingIssues(text) {\n    if (!text) return text;\n    \n    // Common replacements for incorrectly encoded characters\n    const replacements = [\n      // Fix smart quotes and apostrophes\n      { pattern: /â\\u0080\\u0099/g, replacement: \"\\u2019\" },  // Right single quote/apostrophe\n      { pattern: /â\\u0080\\u009c/g, replacement: \"\\u201C\" },  // Left double quote\n      { pattern: /â\\u0080\\u009d/g, replacement: \"\\u201D\" },  // Right double quote\n      { pattern: /â\\u0080\\u0098/g, replacement: \"\\u2018\" },  // Left single quote\n      \n      // Fix dashes and ellipsis\n      { pattern: /â\\u0080\\u0093/g, replacement: \"\\u2013\" },  // En dash\n      { pattern: /â\\u0080\\u0094/g, replacement: \"\\u2014\" },  // Em dash\n      { pattern: /â\\u0080¦/g,   replacement: \"\\u2026\" },      // Ellipsis\n      \n      // Remove non-breaking space indicator\n      { pattern: /Â/g, replacement: \"\" },\n      \n      // Fix fractions\n      { pattern: /Â½/g, replacement: \"\\u00BD\" },             // Half fraction\n  \n      // Fix bullet point\n      { pattern: /â€¢/g, replacement: \"•\" },\n  \n      // Fix common mis-encoded accented characters\n      { pattern: /Ã©/g, replacement: \"é\" },\n      { pattern: /Ã¨/g, replacement: \"è\" },\n      { pattern: /Ã¢/g, replacement: \"â\" },\n      { pattern: /Ãª/g, replacement: \"ê\" },\n      { pattern: /Ã«/g, replacement: \"ë\" },\n      { pattern: /Ã®/g, replacement: \"î\" },\n      { pattern: /Ã¯/g, replacement: \"ï\" },\n      { pattern: /Ã´/g, replacement: \"ô\" },\n      { pattern: /Ã¶/g, replacement: \"ö\" },\n      { pattern: /Ã¹/g, replacement: \"ù\" },\n      { pattern: /Ãº/g, replacement: \"ú\" },\n      { pattern: /Ã¼/g, replacement: \"ü\" },\n      { pattern: /Ã§/g, replacement: \"ç\" }\n    ];\n    \n    // Apply all replacements\n    let fixedText = text;\n    for (const { pattern, replacement } of replacements) {\n      fixedText = fixedText.replace(pattern, replacement);\n    }\n  \n    return fixedText;\n  }\n    \n  // Apply the fix to all post captions when the page loads\n  document.addEventListener('DOMContentLoaded', function() {\n    // Fix the JSON data directly\n    for (const timestamp in window.postData) {\n      const post = window.postData[timestamp];\n      if (post.tt) {  // Changed from title\n        post.tt = fixEncodingIssues(post.tt);  // Changed from title\n      }\n    }\n    \n    // Update any already rendered content\n    const captions = document.querySelectorAll('.post-caption');\n    captions.forEach(caption => {\n      caption.textContent = fixEncodingIssues(caption.textContent);\n    });\n  });\n  \n"
  },
  {
    "path": "memento_mori/static/js/stories.js",
    "content": "// Stories viewer functionality\ndocument.addEventListener('DOMContentLoaded', function() {\n    // Story viewer elements\n    const storyViewer = document.getElementById('storyViewer');\n    const storyMedia = document.getElementById('storyMedia');\n    const storyCaption = document.getElementById('storyCaption');\n    const storyDate = document.getElementById('storyDate');\n    const storyClose = document.getElementById('storyClose');\n    const storyPrev = document.getElementById('storyPrev');\n    const storyNext = document.getElementById('storyNext');\n    const storyProgress = document.getElementById('storyProgress');\n    \n    // Story data and state\n    let currentStoryIndex = 0;\n    let storyItems = [];\n    let autoProgressTimer = null;\n    let isNavigating = false;\n    const autoProgressDelay = 10000; // 10 seconds\n    \n    // Initialize story items from the grid\n    const storyGridItems = document.querySelectorAll('.story-item');\n    storyGridItems.forEach(item => {\n        item.addEventListener('click', function() {\n            const storyIndex = parseInt(this.getAttribute('data-index'));\n            openStory(storyIndex);\n        });\n    });\n    \n    // Open a story by index\n    function openStory(index) {\n        // Get all story items in their current order\n        storyItems = Array.from(document.querySelectorAll('.story-item'));\n        currentStoryIndex = storyItems.findIndex(item => parseInt(item.getAttribute('data-index')) === index);\n        \n        if (currentStoryIndex === -1) return;\n        \n        // Show the story viewer\n        storyViewer.style.display = 'flex';\n        document.body.style.overflow = 'hidden'; // Prevent scrolling\n        \n        // Load the current story\n        loadCurrentStory();\n        \n        // Update URL with story info\n        const timestamp = storyItems[currentStoryIndex].getAttribute('data-timestamp');\n        if (timestamp) {\n            const url = new URL(window.location.href);\n            url.searchParams.set('story', timestamp);\n            window.history.pushState({}, '', url);\n        }\n    }\n    \n    // Load the current story content\n    function loadCurrentStory() {\n        if (currentStoryIndex < 0 || currentStoryIndex >= storyItems.length) return;\n        \n        // Clear any existing timer\n        clearAutoProgressTimer();\n        \n        // Reset pause state when loading a new story\n        if (isPaused) {\n            isPaused = false;\n            pauseIcon.style.display = 'block';\n            playIcon.style.display = 'none';\n        }\n        \n        // Reset progress bar\n        storyProgress.style.width = '0%';\n        \n        // Clear previous media\n        storyMedia.innerHTML = '';\n        \n        // Ensure the story media container has the correct class\n        storyMedia.className = 'story-media-container';\n        \n        // Create a new slide\n        const slide = document.createElement('div');\n        slide.className = 'media-slide active';\n        slide.style.opacity = '1';\n        slide.style.transform = 'translateX(0)';\n        \n        // Load content into the slide\n        loadStoryContent(slide, currentStoryIndex);\n        \n        // Add the slide to the container\n        storyMedia.appendChild(slide);\n    }\n    \n    // Start auto-progress timer with visual indicator\n    function startAutoProgressTimer() {\n        // Don't start timer if paused\n        if (isPaused) {\n            console.log('Not starting timer because story is paused');\n            return;\n        }\n        \n        console.log('Starting auto-progress timer with delay:', autoProgressDelay, 'ms');\n        \n        // Animate progress bar\n        storyProgress.style.transition = `width ${autoProgressDelay}ms linear`;\n        storyProgress.style.width = '100%';\n        \n        // Set timer for auto-progression\n        autoProgressTimer = setTimeout(() => {\n            console.log('Auto-progress timer completed, navigating to next story');\n            navigateStory(1);\n        }, autoProgressDelay);\n    }\n    \n    // Clear auto-progress timer\n    function clearAutoProgressTimer() {\n        console.log('Clearing auto-progress timer');\n        clearTimeout(autoProgressTimer);\n        autoProgressTimer = null;\n        \n        // Also clear any video timer if it exists\n        const videoElement = storyMedia.querySelector('video');\n        if (videoElement && videoElement.videoTimer) {\n            clearTimeout(videoElement.videoTimer);\n            videoElement.videoTimer = null;\n        }\n        \n        // Reset progress bar immediately\n        storyProgress.style.transition = 'none';\n        storyProgress.style.width = '0%';\n        \n        // Force a reflow to ensure the transition is reset\n        storyProgress.offsetHeight;\n    }\n    \n    // Helper function to load story content into a slide\n    function loadStoryContent(slide, index) {\n        const storyItem = storyItems[index];\n        const timestamp = storyItem.getAttribute('data-timestamp');\n        const storyData = window.storiesData[timestamp];\n    \n        if (!storyData) {\n            isNavigating = false; // Reset navigation lock if we can't load content\n            return;\n        }\n        \n        // Update story date\n        storyDate.textContent = storyData.d || '';\n        \n        // Create media element based on type\n        const mediaUrl = storyData.m[0]; // Use first media item\n        const isVideo = mediaUrl.endsWith('.mp4') || mediaUrl.endsWith('.mov') || \n                       mediaUrl.endsWith('.avi') || mediaUrl.endsWith('.webm');\n        \n        if (isVideo) {\n            console.log('Loading video story:', mediaUrl);\n            const video = document.createElement('video');\n            video.src = mediaUrl;\n            video.controls = true;\n            video.autoplay = !isPaused; // Only autoplay if not paused\n            video.muted = false;\n            \n            // Force the video to take the full size of its container\n            video.style.width = '100%';\n            video.style.height = '100%';\n            video.style.maxHeight = '90vh';\n            video.style.objectFit = 'contain';\n            \n            // Create a wrapper div to help control dimensions\n            const videoWrapper = document.createElement('div');\n            videoWrapper.style.width = '100%';\n            videoWrapper.style.height = '100%';\n            videoWrapper.style.display = 'flex';\n            videoWrapper.appendChild(video);\n            \n            slide.appendChild(videoWrapper);\n            \n            // Handle video events as in the original loadCurrentStory function\n            video.addEventListener('loadedmetadata', function() {\n                // Once we know the video duration, decide how to handle it\n                const videoLength = video.duration;\n                console.log(`Video duration: ${videoLength}s, Auto-progress delay: ${autoProgressDelay/1000}s`);\n                \n                // Clear any existing video timer first\n                if (video.videoTimer) {\n                    clearTimeout(video.videoTimer);\n                    video.videoTimer = null;\n                }\n                \n                if (videoLength > autoProgressDelay/1000) {\n                    // For longer videos, we'll let them play through once\n                    console.log('Video is longer than auto-progress delay, will play once');\n                    video.loop = false;\n                } else {\n                    // For shorter videos, loop until we reach the delay time\n                    console.log('Video is shorter than auto-progress delay, will loop');\n                    video.loop = true;\n                    \n                    // Set up a timer to move to next story after delay\n                    if (!isPaused) {\n                        video.videoTimer = setTimeout(() => {\n                            if (!isPaused && !isNavigating) {\n                                console.log(`Auto-progress timer completed after ${autoProgressDelay/1000}s`);\n                                navigateStory(1);\n                            }\n                        }, autoProgressDelay);\n                    }\n                }\n                \n                // Start progress bar animation\n                if (!isPaused) {\n                    storyProgress.style.transition = `width ${autoProgressDelay}ms linear`;\n                    storyProgress.style.width = '100%';\n                }\n            });\n            \n            // Store the video element in a variable accessible to the togglePause function\n            currentVideoElement = video;\n            \n        } else {\n            console.log('Loading image story:', mediaUrl);\n            const img = document.createElement('img');\n            \n            // Check if there's a WebP version available for non-WebP images\n            if (!mediaUrl.endsWith('.webp') && \n                (mediaUrl.endsWith('.jpg') || mediaUrl.endsWith('.jpeg') || \n                 mediaUrl.endsWith('.png') || mediaUrl.endsWith('.gif'))) {\n                \n                // Try to use WebP version if it exists\n                const webpUrl = mediaUrl.replace(/\\.(jpg|jpeg|png|gif)$/i, '.webp');\n                \n                img.onerror = function() {\n                    this.onerror = null; // Prevent infinite loop\n                    this.src = mediaUrl; // Fall back to original\n                };\n                \n                img.src = webpUrl;\n            } else {\n                img.src = mediaUrl;\n            }\n            \n            img.alt = storyData.tt || 'Instagram Story';\n            slide.appendChild(img);\n            \n            // Start auto-progress for images\n            if (!isPaused && !isNavigating) {\n                startAutoProgressTimer();\n            }\n        }\n        \n        // Update navigation buttons visibility - always show both buttons for circular navigation\n        storyPrev.style.display = 'flex';\n        storyNext.style.display = 'flex';\n    }\n    \n    // Navigate to previous/next story\n    function navigateStory(direction) {\n        // Prevent rapid clicks from causing issues\n        if (isNavigating) return;\n        isNavigating = true;\n        \n        // If we're paused and this is an automatic navigation (not user-initiated),\n        // don't advance to the next story\n        const isUserInitiated = event && (event.type === 'click' || event.type === 'keydown');\n        if (isPaused && direction > 0 && !isUserInitiated) {\n            console.log('Auto-navigation blocked because story is paused');\n            isNavigating = false;\n            return;\n        }\n        \n        // Always clear any existing timers first\n        clearAutoProgressTimer();\n        \n        // Calculate the new index with circular navigation\n        let newIndex = currentStoryIndex + direction;\n        \n        // Implement circular navigation (only once)\n        if (newIndex < 0) {\n            newIndex = storyItems.length - 1; // Wrap to the last story\n            console.log('Wrapping to the last story');\n        } else if (newIndex >= storyItems.length) {\n            newIndex = 0; // Wrap to the first story\n            console.log('Wrapping to the first story');\n        }\n        \n        // Get the current slide for animation\n        const currentSlide = storyMedia.querySelector('.media-slide.active');\n        \n        // Animate the current slide out\n        if (currentSlide) {\n            currentSlide.style.transition = 'transform 0.5s ease';\n            currentSlide.style.transform = `translateX(${direction < 0 ? '100%' : '-100%'})`;\n            \n            // Create and prepare the new slide with initial position\n            const newSlide = document.createElement('div');\n            newSlide.className = 'media-slide';\n            newSlide.style.transition = 'none'; // No transition initially\n            newSlide.style.transform = `translateX(${direction > 0 ? '100%' : '-100%'})`; // Start from right or left\n            newSlide.style.opacity = '1';\n            \n            // Load the content into the new slide\n            loadStoryContent(newSlide, newIndex);\n            storyMedia.appendChild(newSlide);\n            \n            // Force a reflow to ensure the initial transform is applied\n            newSlide.offsetHeight;\n            \n            // Now animate the slide into view\n            newSlide.style.transition = 'transform 0.5s ease';\n            newSlide.style.transform = 'translateX(0)';\n            \n            // After animation completes, update to the new story\n            setTimeout(() => {\n                currentStoryIndex = newIndex;\n            \n                // Remove old slides\n                const oldSlides = storyMedia.querySelectorAll('.media-slide:not(:last-child)');\n                oldSlides.forEach(slide => slide.remove());\n            \n                // Make the new slide active\n                newSlide.classList.add('active');\n            \n                // Update URL\n                const timestamp = storyItems[currentStoryIndex].getAttribute('data-timestamp');\n                if (timestamp) {\n                    const url = new URL(window.location.href);\n                    url.searchParams.set('story', timestamp);\n                    window.history.pushState({}, '', url);\n                }\n            \n                // Reset navigation lock\n                isNavigating = false;\n            }, 500);\n        } else {\n            // If no current slide (shouldn't happen), just load the new story\n            currentStoryIndex = newIndex;\n            loadCurrentStory();\n        \n            // Update URL\n            const timestamp = storyItems[currentStoryIndex].getAttribute('data-timestamp');\n            if (timestamp) {\n                const url = new URL(window.location.href);\n                url.searchParams.set('story', timestamp);\n                window.history.pushState({}, '', url);\n            }\n        \n            // Reset navigation lock\n            isNavigating = false;\n        }\n    }\n    \n    // Close the story viewer\n    function closeStory() {\n        // Pause any playing videos before closing\n        const videoElements = storyMedia.querySelectorAll('video');\n        videoElements.forEach(video => {\n            if (video && !video.paused) {\n                video.pause();\n            }\n        });\n        \n        clearAutoProgressTimer();\n        storyViewer.style.display = 'none';\n        document.body.style.overflow = ''; // Restore scrolling\n        \n        // Remove story parameter from URL\n        const url = new URL(window.location.href);\n        url.searchParams.delete('story');\n        window.history.pushState({}, '', url);\n    }\n    \n    // Get pause button element\n    const storyPause = document.getElementById('storyPause');\n    const pauseIcon = document.getElementById('pauseIcon');\n    const playIcon = document.getElementById('playIcon');\n    let isPaused = false;\n    let currentVideoElement = null; // Track the current video element\n    \n    // Event listeners\n    storyClose.addEventListener('click', closeStory);\n    storyPrev.addEventListener('click', () => navigateStory(-1));\n    storyNext.addEventListener('click', () => navigateStory(1));\n    storyPause.addEventListener('click', togglePause);\n    \n    // Toggle pause function\n    function togglePause() {\n        console.log('Toggle pause called, current state:', isPaused);\n        isPaused = !isPaused;\n        \n        if (isPaused) {\n            console.log('Pausing story playback');\n            // Show play icon when paused\n            pauseIcon.style.display = 'none';\n            playIcon.style.display = 'block';\n            \n            // Clear the timer and stop progress\n            clearAutoProgressTimer();\n            storyProgress.style.transition = 'none';\n            \n            // Don't pause videos, let them continue playing in loop\n            console.log('Video will continue playing but auto-advance is disabled');\n            \n            // Get the current video if there is one\n            const videoElement = storyMedia.querySelector('video');\n            if (videoElement && videoElement.videoTimer) {\n                clearTimeout(videoElement.videoTimer);\n                videoElement.videoTimer = null;\n            }\n        } else {\n            console.log('Resuming story playback');\n            // Show pause icon when playing\n            pauseIcon.style.display = 'block';\n            playIcon.style.display = 'none';\n            \n            // Get the media element in the story viewer\n            const videoElement = storyMedia.querySelector('video');\n            const isVideo = videoElement !== null;\n            \n            console.log('Is video:', isVideo);\n            \n            if (!isVideo) {\n                console.log('Starting auto progress timer for image');\n                startAutoProgressTimer();\n            } else {\n                console.log('Playing video');\n                videoElement.play();\n            }\n        }\n    }\n    \n    // Keyboard navigation\n    document.addEventListener('keydown', function(e) {\n        if (storyViewer.style.display !== 'none') {\n            if (e.key === 'ArrowLeft') {\n                navigateStory(-1);\n            } else if (e.key === 'ArrowRight') {\n                navigateStory(1);\n            } else if (e.key === 'Escape') {\n                closeStory();\n            }\n        }\n    });\n    \n    // Click on the story media area to navigate forward\n    storyMedia.addEventListener('click', function(e) {\n        // Only if it's not a video (to avoid interfering with video controls)\n        if (!e.target.matches('video')) {\n            navigateStory(1);\n        }\n    });\n    \n    // Check URL for story parameter on page load\n    function checkUrlForStory() {\n        const urlParams = new URLSearchParams(window.location.search);\n        const storyTimestamp = urlParams.get('story');\n        \n        if (storyTimestamp) {\n            // Find the story item with this timestamp\n            const storyItem = Array.from(storyGridItems).find(\n                item => item.getAttribute('data-timestamp') === storyTimestamp\n            );\n            \n            if (storyItem) {\n                const storyIndex = parseInt(storyItem.getAttribute('data-index'));\n                // Slight delay to ensure DOM is fully loaded\n                setTimeout(() => openStory(storyIndex), 100);\n            }\n        }\n    }\n    \n    // Run URL check\n    checkUrlForStory();\n});\n"
  },
  {
    "path": "memento_mori/templates/grid.html",
    "content": "{% for post in posts %}\n<div class=\"grid-item\" data-index=\"{{ post.index }}\">\n  <img src=\"{{ post.display_media }}\" alt=\"Instagram post\"{{ post.lazy_load }}>\n  {% if post.is_video %}\n  <div class=\"video-indicator\">▶ Video</div>\n  {% endif %}\n  {% if post.media_count > 1 %}\n  <div class=\"multi-indicator\">⊞ {{ post.media_count }}</div>\n  {% elif post.likes %}\n  <div class=\"likes-indicator\">♥ {{ post.likes }}</div>\n  {% endif %}\n</div>\n{% endfor %}"
  },
  {
    "path": "memento_mori/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Memento Mori - {{ username }}'s Instagram Archive</title>\n    <link rel=\"stylesheet\" href=\"css/style.css\">\n    {% if gtag_id %}\n    <!-- Google tag (gtag.js) -->\n    <script async src=\"https://www.googletagmanager.com/gtag/js?id={{ gtag_id }}\"></script>\n    <script>\n      window.dataLayer = window.dataLayer || [];\n      function gtag(){dataLayer.push(arguments);}\n      gtag('js', new Date());\n\n      gtag('config', '{{ gtag_id }}');\n    </script>\n    {% endif %}\n    <!-- Script to make post data available to JavaScript -->\n    <script>\n        window.postData = {{ post_data_json|safe }};\n        {% if stories_data_json %}\n        window.storiesData = {{ stories_data_json|safe }};\n        {% else %}\n        window.storiesData = {};\n        {% endif %}\n    </script>\n    <script src=\"js/modal.js\"></script>\n  </head>\n  <body>\n    <header>\n      <div class=\"header-content\">\n        <a href=\"https://github.com/greg-randall/memento-mori\" class=\"logo\">Memento Mori</a>\n        <div class=\"date-range-header\" id=\"date-range-header\">{{ date_range }}</div>\n      </div>\n    </header>\n    <main>\n      <div class=\"profile-info\">\n        <div class=\"profile-picture\">\n          <img alt=\"Profile Picture\" src=\"{{ profile_picture }}\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n        </div>\n        <div class=\"profile-details\">\n          <h1 id=\"username\">{{ username }}</h1>\n          {% if bio %}\n          <p class=\"bio\">{{ bio }}</p>\n          {% endif %}\n          {% if profile.website %}\n          <p class=\"website\"><a href=\"{{ profile.website }}\" target=\"_blank\" rel=\"noopener noreferrer\">{{ profile.website }}</a></p>\n          {% endif %}\n          <div class=\"stats\">\n            <div class=\"stat\">\n              <a href=\"index.html\" class=\"nav-link active\">\n                <span class=\"stat-count\" id=\"post-count\">{{ post_count }}</span> posts\n              </a>\n            </div>\n            {% if has_stories %}\n            <div class=\"stat\">\n              <a href=\"stories.html\" class=\"nav-link\">\n                <span class=\"stat-count\" id=\"story-count\">{{ story_count }}</span> stories\n              </a>\n            </div>\n            {% endif %}\n            {% if profile.follower_count is defined and profile.follower_count is not none %}\n            <div class=\"stat\">\n              <span class=\"stat-count\" id=\"follower-count\">{{ profile.follower_count }}</span> followers\n            </div>\n            {% endif %}\n          </div>\n        </div>\n      </div>\n      <div class=\"sort-options\">\n        <div class=\"sort-row\">\n          <a href=\"#\" class=\"sort-link active\" data-sort=\"newest\">Newest</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"oldest\">Oldest</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-likes\">Most Likes</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-comments\">Most Comments</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"most-views\">Most Views</a>\n          <a href=\"#\" class=\"sort-link\" data-sort=\"random\">Random</a>\n        </div>\n      </div>\n      <div class=\"posts-grid\" id=\"postsGrid\">\n        {{ grid_html|safe }}\n      </div>\n    </main>\n\n    <!-- Modal for post details -->\n    <div class=\"post-modal\" id=\"postModal\">\n        <div class=\"close-modal\" id=\"closeModal\">✕</div>\n        <div class=\"modal-nav modal-prev\" id=\"modalPrev\">❮</div>\n        <div class=\"modal-nav modal-next\" id=\"modalNext\">❯</div>\n        <div class=\"post-modal-content\">\n            <div class=\"post-media\" id=\"postMedia\"></div>\n            <div class=\"post-info\">\n                <div class=\"post-header\">\n                    <div class=\"post-user\" id=\"postUserPic\">\n                        <img src=\"{{ profile_picture }}\" alt=\"Profile\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n                    </div>\n                    <div class=\"post-username\" id=\"postUsername\">{{ username }}</div>\n                </div>\n                <div class=\"post-caption\" id=\"postCaption\"></div>\n                <div class=\"post-stats\" id=\"postStats\"></div>\n                <div class=\"post-date\" id=\"postDate\"></div>\n            </div>\n        </div>\n    </div>\n    \n    <footer>\n      <div class=\"footer-content\">\n        <p>Generated on {{ generation_date }} with <a href=\"https://github.com/greg-randall/memento-mori\">Memento Mori</a>.</p>\n      </div>\n    </footer>\n  </body>\n</html>\n"
  },
  {
    "path": "memento_mori/templates/stories.html",
    "content": "{% for story in stories %}\n  <div class=\"grid-item story-item\" data-index=\"{{ story.i }}\">\n    <div class=\"grid-item-content\">\n      {% if story.v %}\n        <!-- Video story -->\n        <div class=\"media-container video-container\">\n          {% if story.thumb %}\n            <img src=\"{{ story.thumb }}\" alt=\"{{ story.tt|default('Instagram Story') }}\" {{ story.lazy_load|safe }}>\n          {% else %}\n            <div class=\"video-placeholder\">\n              <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\">\n                <path d=\"M8 5v14l11-7z\" fill=\"currentColor\"/>\n              </svg>\n            </div>\n          {% endif %}\n          <div class=\"video-indicator\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\">\n              <path d=\"M8 5v14l11-7z\" fill=\"white\"/>\n            </svg>\n          </div>\n        </div>\n      {% else %}\n        <!-- Image story -->\n        <div class=\"media-container\">\n          <img src=\"{{ story.thumb or story.m }}\" alt=\"{{ story.tt|default('Instagram Story') }}\" {{ story.lazy_load|safe }}>\n        </div>\n      {% endif %}\n      \n      <div class=\"story-info\">\n        <div class=\"story-date\">{{ story.d }}</div>\n      </div>\n    </div>\n  </div>\n{% endfor %}\n"
  },
  {
    "path": "memento_mori/templates/stories_page.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Memento Mori - {{ username }}'s Instagram Stories</title>\n    <link rel=\"stylesheet\" href=\"css/style.css\">\n    {% if gtag_id %}\n    <!-- Google tag (gtag.js) -->\n    <script async src=\"https://www.googletagmanager.com/gtag/js?id={{ gtag_id }}\"></script>\n    <script>\n      window.dataLayer = window.dataLayer || [];\n      function gtag(){dataLayer.push(arguments);}\n      gtag('js', new Date());\n\n      gtag('config', '{{ gtag_id }}');\n    </script>\n    {% endif %}\n    <!-- Script to make stories data available to JavaScript -->\n    <script>\n        window.storiesData = {{ stories_data_json|safe }};\n    </script>\n    <!-- Load stories.js only, modal.js is not needed on this page -->\n    <script src=\"js/stories.js\"></script>\n    <style>\n      /* Make videos full height in story viewer */\n      .story-media-container video {\n        width: 100%;\n        height: 100%;\n        max-height: 80vh;\n        object-fit: contain;\n      }\n      \n      /* Pause button styles */\n      .story-pause {\n        position: absolute;\n        top: 20px;\n        right: 70px;\n        color: white;\n        font-size: 30px;\n        cursor: pointer;\n        z-index: 2001;\n        width: 40px;\n        height: 40px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        background-color: rgba(0, 0, 0, 0.5);\n        border-radius: 50%;\n      }\n      \n      /* 9:16 aspect ratio for story thumbnails */\n      .stories-grid {\n        display: grid;\n        grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n        gap: 15px;\n        margin-top: 20px;\n      }\n      \n      .story-item {\n        position: relative;\n        cursor: pointer;\n        border-radius: 8px;\n        overflow: hidden;\n        box-shadow: 0 2px 5px rgba(0,0,0,0.1);\n        transition: transform 0.3s ease;\n      }\n      \n      .story-item:hover {\n        transform: translateY(-5px);\n      }\n      \n      .story-media {\n        position: relative;\n        width: 100%;\n        padding-bottom: 177.78%; /* 16:9 aspect ratio (9/16 = 0.5625, 1/0.5625 = 1.7778 or 177.78%) */\n        overflow: hidden;\n      }\n      \n      .story-media img {\n        position: absolute;\n        top: 0;\n        left: 0;\n        width: 100%;\n        height: 100%;\n        object-fit: cover;\n      }\n      \n      .story-info {\n        position: absolute;\n        bottom: 0;\n        left: 0;\n        right: 0;\n        background: rgba(0,0,0,0.7);\n        color: white;\n        padding: 5px;\n        font-size: 12px;\n        text-align: center;\n      }\n    </style>\n  </head>\n  <body>\n    <header>\n      <div class=\"header-content\">\n        <a href=\"https://github.com/greg-randall/memento-mori\" class=\"logo\">Memento Mori</a>\n        <div class=\"date-range-header\" id=\"date-range-header\">{{ date_range }}</div>\n      </div>\n    </header>\n    <main>\n      <div class=\"profile-info\">\n        <div class=\"profile-picture\">\n          <img alt=\"Profile Picture\" src=\"{{ profile_picture }}\" style=\"width: 100%; height: 100%; object-fit: cover; border-radius: 50%;\">\n        </div>\n        <div class=\"profile-details\">\n          <h1 id=\"username\">{{ username }}</h1>\n          {% if bio %}\n          <p class=\"bio\">{{ bio }}</p>\n          {% endif %}\n          {% if profile.website %}\n          <p class=\"website\"><a href=\"{{ profile.website }}\" target=\"_blank\" rel=\"noopener noreferrer\">{{ profile.website }}</a></p>\n          {% endif %}\n          <div class=\"stats\">\n            <div class=\"stat\">\n              <a href=\"index.html\" class=\"nav-link\">\n                <span class=\"stat-count\" id=\"post-count\">{{ post_count }}</span> posts\n              </a>\n            </div>\n            <div class=\"stat\">\n              <a href=\"stories.html\" class=\"nav-link active\">\n                <span class=\"stat-count\" id=\"story-count\">{{ story_count }}</span> stories\n              </a>\n            </div>\n            {% if profile.follower_count is defined and profile.follower_count is not none %}\n            <div class=\"stat\">\n              <span class=\"stat-count\" id=\"follower-count\">{{ profile.follower_count }}</span> followers\n            </div>\n            {% endif %}\n          </div>\n        </div>\n      </div>\n      \n      <div class=\"stories-container\">\n        <h2 class=\"section-title\">Stories</h2>\n        <div class=\"stories-grid\">\n          {% for story in stories %}\n          <div class=\"story-item\" data-index=\"{{ story.index }}\" data-timestamp=\"{{ story.timestamp }}\">\n            <div class=\"story-media\">\n              {% if story.is_video %}\n              <div class=\"video-indicator\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\">\n                  <path d=\"M8 5v14l11-7z\" fill=\"white\"/>\n                </svg>\n              </div>\n              {% endif %}\n              <img src=\"{{ story.media }}\" alt=\"{{ story.caption|default('Instagram Story') }}\" {{ story.lazy_load|safe }} onerror=\"this.onerror=null; this.src='{{ story.original_media }}';\">\n            </div>\n            <div class=\"story-info\">\n              <div class=\"story-date\">{{ story.date }}</div>\n            </div>\n          </div>\n          {% endfor %}\n        </div>\n      </div>\n    </main>\n\n    <!-- Story Viewer -->\n    <div class=\"story-viewer\" id=\"storyViewer\">\n      <div class=\"story-close\" id=\"storyClose\">✕</div>\n      <div class=\"story-pause\" id=\"storyPause\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" id=\"pauseIcon\">\n          <path d=\"M6 4h4v16H6V4zm8 0h4v16h-4V4z\" fill=\"white\"/>\n        </svg>\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" id=\"playIcon\" style=\"display:none;\">\n          <path d=\"M8 5v14l11-7z\" fill=\"white\"/>\n        </svg>\n      </div>\n      <div class=\"story-nav story-prev\" id=\"storyPrev\">❮</div>\n      <div class=\"story-nav story-next\" id=\"storyNext\">❯</div>\n      <div class=\"story-progress-container\">\n        <div class=\"story-progress\" id=\"storyProgress\"></div>\n      </div>\n      <div class=\"story-content\">\n        <div class=\"story-media-container\" id=\"storyMedia\"></div>\n        <div class=\"story-info-overlay\">\n          <div class=\"story-date\" id=\"storyDate\"></div>\n        </div>\n      </div>\n    </div>\n    \n    <footer>\n      <div class=\"footer-content\">\n        <p>Generated on {{ generation_date }} with <a href=\"https://github.com/greg-randall/memento-mori\">Memento Mori</a>.</p>\n      </div>\n    </footer>\n  </body>\n</html>\n"
  },
  {
    "path": "project_plan.md",
    "content": "# Memento Mori - Instagram Archive Viewer\n\n## Project Overview\n\nMemento Mori is a tool that transforms your Instagram data export into a beautiful, standalone viewer that resembles the Instagram interface. The name \"Memento Mori\" (Latin for \"remember that you will die\") reflects the ephemeral nature of digital content.\n\nThis README outlines the architecture for refactoring the project from a single script into a modular package that supports:\n- Automatic detection and extraction of Instagram exports\n- Modular processing of media and data\n- Docker-based execution\n- Extensible architecture for future enhancements\n\n## Architecture\n\nThe project follows a modular architecture that separates concerns while maintaining simplicity:\n\n### Core Components\n\n1. **File Mapping** (`file_mapper.py`)\n   - Central source of truth for file discovery patterns\n   - Discovers and maps files in Instagram export structure\n   - Provides consistent file access for all components\n\n2. **Archive Extraction** (`extractor.py`)\n   - Detects and extracts Instagram data archives\n   - Identifies required files in the archive structure\n   - Creates a file mapper instance for use by other components\n\n3. **Data Loading & Processing** (`loader.py`)\n   - Loads JSON data from Instagram export using file mapper\n   - Processes and merges posts with insights\n   - Creates structured data models\n\n4. **Media Processing** (`media.py`)\n   - Converts images to optimized formats (WebP)\n   - Generates thumbnails for images and videos\n   - Organizes media files in the output directory\n\n5. **Website Generation** (`generator.py`)\n   - Creates HTML/CSS/JS files using templates\n   - Renders Instagram-like interface\n   - Verifies output for completeness\n\n6. **Command Line Interface** (`cli.py`)\n   - Processes command-line arguments\n   - Coordinates the execution flow\n   - Provides user feedback\n\n## Project Structure\n\n```\nmemento-mori/\n├── pyproject.toml               # Package metadata and dependencies\n├── README.md                    # Documentation\n├── Dockerfile                   # Docker container configuration\n├── docker-compose.yml           # For easy Docker operation\n├── memento_mori/\n│   ├── __init__.py              # Package version, exports\n│   ├── cli.py                   # Command-line interface\n│   ├── config.py                # Configuration handling\n│   ├── file_mapper.py           # File discovery and mapping\n│   ├── extractor.py             # Archive detection and extraction\n│   ├── loader.py                # Load and process Instagram data\n│   ├── media.py                 # Media processing (conversion, thumbnails)\n│   ├── generator.py             # Website generation\n│   ├── templates/               # HTML templates\n│   │   ├── index.html           # Main template\n│   │   └── components/          # Reusable components\n│   │       └── modal.html       # Post modal component\n│   ├── static/                  # Static assets\n│   │   ├── css/\n│   │   │   └── style.css\n│   │   └── js/\n│   │       └── modal.js\n│   └── utils.py                 # Common utilities and helpers\n└── tests/                       # Tests directory\n```\n\n## Component Details\n\n### file_mapper.py\n\nProvides a central location for file discovery and mapping:\n- Defines patterns for locating important files in Instagram exports\n- Discovers files based on patterns and maps them to easy-to-use identifiers\n- Ensures consistency between different components accessing the same files\n- Provides validation for required files\n- Handles variations in Instagram export structure gracefully\n\n### extractor.py\n\nResponsible for locating and extracting Instagram data archives:\n- Auto-detection of ZIP files in specified directories\n- Extraction of archives to temporary or specified locations\n- Creates a file_mapper instance for the extracted content\n- Validation of extracted content structure via file_mapper\n- Cleanup of temporary files after processing\n\n### loader.py\n\nHandles loading and processing Instagram data:\n- Uses file_mapper to access JSON files (posts, insights, user data)\n- Parsing and merging data sources\n- Converting timestamps and other data formatting\n- Providing a clean data structure for the generator\n\n### media.py\n\nManages all media processing operations:\n- Converting images to WebP format when beneficial\n- Generating thumbnails for grid view\n- Creating video thumbnails using appropriate libraries\n- Parallel processing of media files\n- Tracking conversion statistics\n\n### generator.py\n\nCreates the static website:\n- Using templates instead of hardcoded HTML\n- Generating responsive layout\n- Including JavaScript for interactive features\n- Verifying output completeness\n- Supporting customization options\n\n### cli.py\n\nProvides command-line interface:\n- Processing command-line arguments\n- Validating inputs\n- Coordinating processing flow \n- Using the file_mapper for consistent file access\n- Reporting progress and statistics\n\n## Processing Flow\n\nThe typical processing flow is:\n\n```python\n# Initialize extractor\nextractor = InstagramArchiveExtractor()\n\n# Extract archive\nextractor.auto_detect_archive()\nextraction_dir = extractor.extract()\n\n# Get file mapper from extractor\nfile_mapper = extractor.file_mapper\n\n# Initialize loader with the same file mapper\nloader = InstagramDataLoader(extraction_dir, file_mapper)\n\n# Load and process data\ndata = loader.load_all_data()\n\n# Generate website with the loaded data\ngenerator = WebsiteGenerator(data, output_dir)\ngenerator.generate()\n```\n\n## Implementation Roadmap\n\n### Phase 1: Basic Structure\n1. Create package structure\n2. Implement file_mapper for centralized file discovery\n3. Move core functionality from original script to appropriate modules\n4. Create basic CLI\n\n### Phase 2: Features\n1. Implement archive auto-detection\n2. Add archive extraction\n3. Implement templating system\n4. Enhance media processing\n\n### Phase 3: Packaging & Deployment\n1. Create Docker configuration\n2. Set up package installation\n3. Add documentation\n4. Create tests\n\n## Docker Usage\n\nThe Docker configuration will allow easy execution:\n\n```\n# Run using docker-compose\ndocker-compose run --rm memento-mori --input /input/instagram-export.zip --output /output\n\n# Or directly with docker\ndocker run -v $(pwd)/input:/input -v $(pwd)/output:/output memento-mori --input /input/instagram-export.zip\n```\n\n## CLI Usage\n\n```\nUsage: memento-mori [OPTIONS]\n\nOptions:\n  --input PATH         Path to Instagram data (ZIP or folder)\n  --output PATH        Output directory for generated website [default: ./distribution]\n  --threads INTEGER    Number of parallel processing threads [default: auto]\n  --auto-detect        Auto-detect Instagram export in current directory\n  --quality INTEGER    WebP conversion quality (1-100) [default: 80]\n  --thumbnail-size WxH Size of thumbnails [default: 292x292]\n  --help               Show this message and exit\n```\n\n## Future Extensions\n\nThe architecture supports several planned extensions:\n1. Multiple archive merging\n2. Custom themes\n3. Additional statistics and visualizations\n4. Progressive enhancement of the viewer\n5. Support for Stories and other Instagram content types\n6. Support for different Instagram export formats as they evolve\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=42\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"memento-mori\"\nversion = \"0.1.0\"\ndescription = \"Transform Instagram data export into a viewer\"\nreadme = \"README.md\"\nrequires-python = \">=3.9\"\nlicense = \"MIT\"\ndependencies = [\n    \"ftfy==6.3.1\",\n    \"Jinja2==3.0.3\",\n    \"MarkupSafe==2.1.5\",\n    \"opencv_python==4.10.0.84\",\n    \"Pillow==11.1.0\",\n    \"python_magic>=0.4.27\",\n    \"tqdm==4.67.1\"\n]\n\n[tool.setuptools]\npackages = [\"memento_mori\"]\n\n[project.scripts]\nmemento-mori = \"memento_mori.cli:main\"\n\n[project.urls]\n\"Homepage\" = \"https://github.com/greg-randall/memento-mori\"\n\"Bug Tracker\" = \"https://github.com/greg-randall/memento-mori/issues\""
  },
  {
    "path": "requirements.txt",
    "content": "ftfy==6.3.1\nJinja2==3.0.3\nMarkupSafe==2.1.5\nopencv_python==4.10.0.84\nPillow>=11.1.0\npython_magic==0.4.27\ntqdm==4.67.1\n"
  }
]