[
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n  \"env\": {\n    \"es2021\": true,\n    \"node\": true\n  },\n  \"extends\": \"eslint:recommended\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 13,\n    \"sourceType\": \"module\"\n  },\n  \"ignorePatterns\": [\n    \"build/**/*.js\"\n  ],\n  \"rules\": {\n  }\n};\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ncustom: https://dosaygo.com/downloadnet\n"
  },
  {
    "path": ".github/workflows/node.js.yml",
    "content": "# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node\n# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs\n\nname: Node.js CI\n\non:\n  push:\n    branches: [ \"fun\" ]\n  pull_request:\n    branches: [ \"fun\" ]\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [16.x, 18.x, 19.x]\n        # See supported Node.js release schedule at https://nodejs.org/en/about/releases/\n\n    steps:\n    - uses: actions/checkout@v3\n    - name: Use Node.js ${{ matrix.node-version }}\n      uses: actions/setup-node@v3\n      with:\n        node-version: ${{ matrix.node-version }}\n        cache: 'npm'\n    - run: npm ci\n    - run: npm run build --if-present\n    - run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": "*.pkg\n\"\n\"*\n*~\n.*.un~\n*.blob\n.\\build\\*\n22120-arc\n\n.*.swp\n\n# Bundling and packaging\n22120.exe\n22120.nix\n22120.mac\n22120.win32.exe\n22120.nix32\nbin/*\nbuild/*\n\n#Leave these to allow install by npm -g\n#22120.js\n#*.22120.js\n\n# Library\npublic/library/cache.json\npublic/library/http*\n\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n\n# Next.js build output\n.next\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and *not* Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n"
  },
  {
    "path": ".npm.release",
    "content": "Sun Jan 15 15:11:49 CST 2023\n"
  },
  {
    "path": ".npmignore",
    "content": "\n.*.swp\n*~\n.*un~\n\n# Bundling and packaging\nbuild/bin/*\n\nbuild/cjs/*\n\n"
  },
  {
    "path": ".npmrelease",
    "content": "Fri Aug 30 00:09:47 CST 2024\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nWhen contributing to this repository, please first discuss the change you wish to make via issue,\nemail, or any other method with the owners of this repository before making a change. \n\nPlease note we have a code of conduct, please follow it in all your interactions with the project.\n\n## Pull Request Process\n\n1. Ensure any install or build dependencies are removed before the end of the layer when doing a \n   build.\n2. Update the README.md with details of changes to the interface, this includes new environment \n   variables, exposed ports, useful file locations and container parameters.\n3. Increase the version numbers in any examples files and the README.md to the new version that this\n   Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).\n4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you \n   do not have permission to do that, you may request the second reviewer to merge it for you.\n\n## Code of Conduct\n\n### Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, gender identity and expression, level of experience,\nnationality, personal appearance, race, religion, or sexual identity and\norientation.\n\n### Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or\nadvances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n### Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n### Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n### Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team at [INSERT EMAIL ADDRESS]. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n### Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at [http://contributor-covenant.org/version/1/4][version]\n\n[homepage]: http://contributor-covenant.org\n[version]: http://contributor-covenant.org/version/1/4/\n"
  },
  {
    "path": "NOTICE",
    "content": "Copyright Dosyago Corporation & Cris Stringfellow (https://dosaygo.com)\n\n22120 and all previously released versions, including binaries, NPM packages, and \nDocker images (including all named archivist1, and all other previous names)\nis re-licensed under the following PolyForm Strict License 1.0.0 and all previous\nlicenses are revoked.\n\n"
  },
  {
    "path": "README.md",
    "content": "# :floppy_disk: [DownloadNet (dn)](https://github.com/dosyago/DownloadNet) – Your Offline Web Archive with Full Text Search\n\n![source lines of code](https://sloc.xyz/github/crisdosyago/Diskernet)\n![binary downloads](https://img.shields.io/github/downloads/c9fe/22120/total?label=OS%20binary%20downloads)\n![DownloadNet slogan](https://img.shields.io/badge/%F0%9F%92%BE%20dn-an%20internet%20on%20yer%20disc-hotpink)\n\nImagine a world where everything you browse online is saved and accessible, even when you're offline. That's the magic of DownloadNet (dn).\n\n## Why dn?\n\n- **Seamless Offline Experience** :earth_africa:: With dn, your offline browsing feels exactly like being online. It hooks directly into your browser, caching every page you visit, so you never lose track of that one article or resource you meant to revisit.\n- **Full Text Search** :mag:: Unlike other archiving tools, dn gives you the power to search through your entire archive. No more digging through countless files—just search and find.\n- **Completely Private** :lock:: Everything is stored locally on your machine. Browse whatever you want, with the peace of mind that it's all private and secure.\n\n## Getting Started\n\n### 1. **Download a Pre-built Binary (Simplest Option)** :package:\nIf you’re not familiar with Git or npm, this is the easiest way to get started:\n\n1. **Go to the [Releases Page](https://github.com/dosyago/DownloadNet/releases)**\n2. **Download** the binary for your operating system (e.g., Windows, macOS, Linux).\n3. **Run** the downloaded application. That’s it! You’re ready to start archiving.\n\n>[!NOTE]\n> macOS now has a proper package installer, so it will be even easier. \n\n### 2. **Install via npm (For Users Familiar with Command Line)** :rocket:\n\n1. **Open your terminal** (Command Prompt on Windows, Terminal on macOS/Linux).\n2. **Install dn globally** with npm:\n   ```sh\n   npm i -g downloadnet@latest\n   ```\n3. **Start dn** by typing:\n   ```sh\n   dn\n   ```\n\n> [!NOTE]\n> Make sure you have Node.js installed before attempting to use npm. If you're new to npm, see the next section for guidance.\n\n### 3. **New to npm? No Problem!** :bulb:\n\nIf you’ve never used npm before, don’t worry—it’s easy to get started.\n\n- **What is npm?** npm is a package manager for Node.js, a JavaScript runtime that allows you to run server-side code. You’ll use npm to install and manage software like dn.\n- **Installing Node.js and npm:** The easiest way to install Node.js (which includes npm) is by using Node Version Manager (nvm). This tool allows you to easily install, manage, and switch between different versions of Node.js.\n\n**To install nvm:**\n\n1. **Visit the [nvm GitHub page](https://github.com/nvm-sh/nvm#installing-and-updating)** for installation instructions.\n2. **Follow the steps** to install nvm on your system.\n3. Once nvm is installed, **install the latest version of Node.js** by running:\n   ```sh\n   nvm install node\n   ```\n4. Now you can install dn using npm as described in the section above!\n\n> [!TIP]\n> Using nvm allows you to easily switch between Node.js versions and manage your environment more effectively.\n\n### 4. **Build Your Own Binary (For Developers or Power Users)** :hammer_and_wrench:\n\nIf you like to tinker and want to build the binary yourself, here’s how:\n\n1. **Download Git:** If you haven’t used Git before, download and install it from [git-scm.com](https://git-scm.com/).\n2. **Clone the Repository:**\n   ```sh\n   git clone https://github.com/dosyago/DownloadNet.git\n   ```\n3. **Navigate to the Project Directory:**\n   ```sh\n   cd DownloadNet\n   ```\n4. **Install Dependencies:**\n   ```sh\n   npm i\n   ```\n5. **Build the Binary:**\n   ```sh\n   npm run build\n   ```\n\n6. **Find Your Binary:** The newly built binary will be in the `./build/bin` directory, ready to be executed!\n\n### 5. **Run Directly from the Repository (Quick Start)** :runner:\n\nWant to get dn up and running without building a binary? No problem!\n\n1. **Clone the Repository:**\n   ```sh\n   git clone https://github.com/dosyago/DownloadNet.git\n   ```\n2. **Navigate to the Project Directory:**\n   ```sh\n   cd DownloadNet\n   ```\n3. **Install Dependencies:**\n   ```sh\n   npm i\n   ```\n4. **Start dn:**\n   ```sh\n   npm start\n   ```\n\nAnd just like that, you’re archiving!\n\n## How It Works\n\ndn runs as an intercepting proxy, hooking into your browser's internal fetch cycle. Once you fire up dn, it automatically configures your browser, and you’re good to go. Everything you browse is archived, and you can choose to save everything or just what you bookmark.\n\n### Modes:\n\n- **Save Mode** :floppy_disk:: Archive and index as you browse.\n- **Serve Mode** :open_file_folder:: Browse your saved content as if you were still online.\n\n> [!CAUTION]\n> As your archive grows, you may encounter performance issues. If that happens, you can adjust the memory settings by setting environment variables for NODE runtime arguments, like `--max-old-space-size`.\n\n## Accessing Your Archive\n\nOnce dn is running, your archive is at your fingertips. Just go to `http://localhost:22120` in your browser. Your archive’s control panel opens automatically, and from there, you can search, configure settings, and explore everything you’ve saved.\n\n## Minimalistic Interface, Maximum Power\n\ndn’s interface is basic but functional. It’s not about flashy design; it’s about delivering what you need—offline access to the web, as if you were still connected.\n\n## Advanced Settings (If Needed)\n\nAs your archive grows, you may want to adjust where it's stored, manage memory settings, or blacklist domains you don’t want to archive. All of these settings can be tweaked directly from the control panel or command line.\n\n## Get Started Now\n\nWith dn, you’ll never lose track of anything you’ve read online. It’s all right there in your own offline archive, fully searchable and always accessible. Whether you're in save mode or serve mode, dn keeps your digital life intact.\n\n**:arrow_down: Download** | **:rocket: Install** | **:runner: Run** | **:mag_right: Never Lose Anything Again**\n\n[Get Started with dn](https://github.com/dosyago/DownloadNet)\n\n----\n"
  },
  {
    "path": "TODO",
    "content": "Ultimate Goal\n\n- stable across releases (binaries, npm, can add to winget/choco in future)\n- revenue\n\n----\n\nReleases\n\n- macos signed\n- win signed\n- linux \n- release per arch where relevant as well\n\nFuture\n\n  - UX to select an existing Chrome profile from standard locations. Ident via Google Profile Picture.png and then copy to a $USER_DATA_DIR/Default directory (rsync or robocopy maybe) and pass --user-data-dir=$USER_DATA_DIR for it to just work as of chrome 136 anyway. \n  - consider other mitigations if these are ineffective (watermark in archvies, other limitations, closed source / more advanced features unlocked, etc)\n    - More future tasks:\n      Marketing\n\n      - /download-net page on dosaygo.com\n\n      Crawl fixes\n\n      - make batch size work\n      - ensure no more than tab from any domain per batch (so that between loads timeouts are enforced)\n      - save crawl in a \"running crawls page\"\n      - be able to pause a crawl and restart it (should be simple), and crawl state persisted to disk.\n\n      Add product key\n\n      - product key section in crawl and settings\n      - 15 minutes then shutdown\n      - no free eval license key \n      - license is 69 per seat per year\n      - plumbing for the backend\n\n      Dev\n\n      - add cross plat node exec.js for scripts\n\n"
  },
  {
    "path": "docs/OLD-README.md",
    "content": "# :floppy_disk: [DownloadNet](https://github.com/c9fe/22120) [![source lines of code](https://sloc.xyz/github/crisdosyago/Diskernet)](https://sloc.xyz) [![npm downloads (22120)](https://img.shields.io/npm/dt/archivist1?label=npm%20downloads%20%2822120%29)](https://npmjs.com/package/archivist1) [![npm downloads (downloadnet, since Jan 2022)](https://img.shields.io/npm/dt/downloadnet?label=npm%20downloads%20%28downloadnet%2C%20since%20Jan%202022%29)](https://npmjs.com/package/downloadnet) [![binary downloads](https://img.shields.io/github/downloads/c9fe/22120/total?label=OS%20binary%20downloads)](https://GitHub.com/crisdosyago/DownloadNet/releases) [![visitors+++](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fc9fe%2F22120&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=%28today%2Ftotal%29%20visitors%2B%2B%2B%20since%20Oct%2027%202020&edge_flat=false)](https://hits.seeyoufarm.com) ![version](https://img.shields.io/npm/v/archivist1)\n\n:floppy_disk: - an internet on yer Disk\n\n**DownloadNet** (codename *PROJECT 22120*) is an archivist browser controller that caches everything you browse, a library server with full text search to serve your archive. \n\n**Now with full text search over your archive.** \n\nThis feature is just released in version 2 so it will improve over time.\n\n## And one more thing...\n\n**Coming to a future release, soon!**: The ability to publish your own search engine that you curated with the best resources based on your expert knowledge and experience.\n\n## Get it\n\n[Download a release](https://github.com/crisdosyago/Diskernet/releases)\n\nor ...\n\n**Get it on [npm](https://www.npmjs.com/package/downloadnet):**\n\n```sh\n$ npm i -g downloadnet@latest\n```\n\nor...\n\n**Build your own binaries:**\n\n```sh\n$ git clone https://github.com/crisdosyago/DownloadNet\n$ cd DownloadNet\n$ npm i \n$ ./scripts/build_setup.sh\n$ ./scripts/compile.sh\n$ cd bin/\n```\n\n<span id=toc></span>\n----------------\n- [Overview](#classical_building-22120---)\n  * [License](#license)\n  * [About](#about)\n  * [Get 22120](#get-22120)\n  * [Using](#using)\n    + [Pick save mode or serve mode](#pick-save-mode-or-serve-mode)\n    + [Exploring your 22120 archive](#exploring-your-22120-archive)\n  * [Format](#format)\n  * [Why not WARC (or another format like MHTML) ?](#why-not-warc-or-another-format-like-mhtml-)\n  * [How it works](#how-it-works)\n  * [FAQ](#faq)\n    + [Do I need to download something?](#do-i-need-to-download-something)\n    + [Can I use this with a browser that's not Chrome-based?](#can-i-use-this-with-a-browser-thats-not-chrome-based)\n    + [How does this interact with Ad blockers?](#how-does-this-interact-with-ad-blockers)\n    + [How secure is running chrome with remote debugging port open?](#how-secure-is-running-chrome-with-remote-debugging-port-open)\n    + [Is this free?](#is-this-free)\n    + [What if it can't find my chrome?](#what-if-it-cant-find-my-chrome)\n    + [What's the roadmap?](#whats-the-roadmap)\n    + [What about streaming content?](#what-about-streaming-content)\n    + [Can I black list domains to not archive them?](#can-i-black-list-domains-to-not-archive-them)\n    + [Is there a DEBUG mode for troubleshooting?](#is-there-a-debug-mode-for-troubleshooting)\n    + [Can I version the archive?](#can-i-version-the-archive)\n    + [Can I change the archive path?](#can-i-change-the-archive-path)\n    + [Can I change this other thing?](#can-i-change-this-other-thing)\n\n------------------\n\n## License \n\n22120 is licensed under Polyform Strict License 1.0.0 (no modification, no distribution). You can purchase a license for different uses below:\n\n\n-  for personal, research, noncommercial purposes: \n[Buy a Perpetual Non-commercial Use License of the current Version re-upped Monthly to the Latest Version, USD$1.99 per month](https://buy.stripe.com/fZeg0a45zdz58U028z) [Read license](https://github.com/DOSYCORPS/polyform-licenses/blob/1.0.0/PolyForm-Noncommercial-1.0.0.md)\n- for part of your internal tooling in your org: [Buy a Perpetual Internal Use License of the current Version re-upped Monthly to the Latest Version, USD $12.99 per month](https://buy.stripe.com/00g4hsgSlbqXb288wY) [Read license](https://github.com/DOSYCORPS/polyform-licenses/blob/1.0.0/PolyForm-Internal-Use-1.0.0.md)\n- for anywhere in your business: [Buy a Perpetual Small-medium Business License of the current Version re-upped Monthly to the Latest Version, USD $99 per month](https://buy.stripe.com/aEUbJUgSl2UreekdRj) [Read license](https://github.com/DOSYCORPS/polyform-licenses/blob/1.0.0/PolyForm-Small-Business-1.0.0.md)\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## About\n\n**This project literally makes your web browsing available COMPLETELY OFFLINE.** Your browser does not even know the difference. It's literally that amazing. Yes. \n\nSave your browsing, then switch off the net and go to `http://localhost:22120` and switch mode to **serve** then browse what you browsed before. It all still works.\n\n**warning: if you have Chrome open, it will close it automatically when you open 22120, and relaunch it. You may lose any unsaved work.**\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## Get 22120\n\n3 ways to get it:\n\n1. Get binary from the [releases page.](https://github.com/c9fe/22120/releases), or\n2. Run with npx: `npx downloadnet@latest`, or\n    - `npm i -g downloadnet@latest && exlibris`\n3. Clone this repo and run as a Node.JS app: `npm i && npm start` \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## Using\n\n### Pick save mode or serve mode\n\nGo to http://localhost:22120 in your browser, \nand follow the instructions. \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Exploring your 22120 archive\n\nArchive will be located in `22120-arc/public/library`\\*\n\nBut it's not public, don't worry!\n\nYou can also check out the archive index, for a listing of every title in the archive. The index is accessible from the control page, which by default is at [http://localhost:22120](http://localhost:22120) (unless you changed the port).\n\n\\**Note:`22120-arc` is the archive root of a single archive, and by defualt it is placed in your home directory. But you can change the parent directory for `22120-arc` to have multiple archvies.*\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## Format\n\nThe archive format is:\n\n`22120-arc/public/library/<resource-origin>/<path-hash>.json`\n\nInside the JSON file, is a JSON object with headers, response code, key and a base 64 encoded response body.\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## Why not WARC (or another format like MHTML) ?\n\n**The case for the 22120 format.**\n\nOther formats (like MHTML and SingleFile) save translations of the resources you archive. They create modifications, such as altering the internal structure of the HTML, changing hyperlinks and URLs into \"flat\" embedded data URIs, or local references, and require other \"hacks* in order to save a \"perceptually similar\" copy of the archived resource.\n\n22120 throws all that out, and calls rubbish on it. 22120 saves a *verbatim* **high-fidelity** copy of the resources your archive. It does not alter their internal structure in any way. Instead it records each resource in its own metadata file. In that way it is more similar to HAR and WARC, but still radically different. Compared to WARC and HAR, our format is radically simplified, throwing out most of the metadata information and unnecessary fields these formats collect.\n\n**Why?**\n\nAt 22120, we believe in the resources and in verbatim copies. We don't annoint ourselves as all knowing enough to modify the resource source of truth before we archive it, just so it can \"fit the format* we choose. We don't believe we need to decorate with obtuse and superfluous metadata. We don't believe we should be modifying or altering resources we archive. We belive we should save them exactly as they were presented. We believe in simplicity. We believe the format should fit (or at least accommodate, and be suited to) the resource, not the other way around. We don't believe in conflating **metadata** with **content**; so we separate them. We believe separating metadata and content, and keeping the content pure and altered throughout the archiving process is not only the right thing to do, it simplifies every part of the audit trail, because we know that the modifications between archived copies of a resource of due to changes to the resources themselves, not artefacts of the format or archiving process.\n\nBoth SingleFile and MHTML require mutilatious modifications of the resources so that the resources can be \"forced to fit\" the format. At 22120, we believe this is not required (and in any case should never be performed). We see it as akin to lopping off the arms of a Roman statue in order to fit it into a presentation and security display box. How ridiculous! The web may be a more \"pliable\" medium but that does not mean we should treat it without respect for its inherent content. \n\n**Why is changing the internal structure of resources so bad?**\n\nIn our view, the internal structure of the resource as presented, *is the cannon*. Internal structure is not just substitutable \"presentation\" - no, in fact it encodes vital semantic information such as hyperlink relationships, source choices, and the \"strokes\" of the resource author as they create their content, even if it's mediated through a web server or web framework. \n\n**Why else is 22120 the obvious and natural choice?**\n\n22120 also archives resources exactly as they are sent to the browser. It runs connected to a browser, and so is able to access the full-scope of resources (with, currently, the exception of video, audio and websockets, for now) in their highest fidelity, without modification, that the browser receives and is able to archive them in the exact format presented to the user. Many resources undergo presentational and processing changes before they are presented to the user. This is the ubiquitous, \"web app\", where client-side scripting enabled by JavaScript, creates resources and resource views on the fly. These sorts of \"hyper resources\" or \"realtime\" or \"client side\" resources, prevalent in SPAs, are not able to be archived, at least not utilizing the normal archive flow, within traditional `wget`-based archiving tools. \n\nIn short, the web is an *online* medium, and it should be archived and presented in the same fashion. 22120 archives content exactly as it is received and presented by a browser, and it also replays that content exactly as if the resource were being taken from online. Yes, it requires a browser for this exercise, but that browser need not be connected to the internet. It is only natural that viewing a web resource requires the web browser. And because of 22120 the browser doesn't know the difference! Resources presented to the browser form a remote web site, and resources given to the browser by 22120, are seen by the browser as ***exactly the same.*** This ensures that the people viewing the archive are also not let down and are given the change to have the exact same experience as if they were viewing the resource online. \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## How it works\n\nUses DevTools protocol to intercept all requests, and caches responses against a key made of (METHOD and URL) onto disk. It also maintains an in memory set of keys so it knows what it has on disk. \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n## FAQ\n\n### Do I need to download something?\n\nYes. But....If you like **22120**, you might love the clientless hosted version coming in future. You'll be able to build your archives online from any device, without any download, then download the archive to run on any desktop. You'll need to sign up to use it, but you can jump the queue and sign up [today](https://dosyago.com).\n\n### Can I use this with a browser that's not Chrome-based? \n\nNo. \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### How does this interact with Ad blockers?\n\nInteracts just fine. The things ad blockers stop will not be archived.\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### How secure is running chrome with remote debugging port open?\n\nSeems pretty secure. It's not exposed to the public internet, and pages you load that tried to use it cannot use the protocol for anything (except to open a new tab, which they can do anyway). It seems there's a potential risk from malicious browser extensions, but we'd need to confirm that and if that's so, work out blocks. See [this useful security related post](https://github.com/c9fe/22120/issues/67) for some info.\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Is this free?\n\nYes this is totally free to download and use for personal non-commercial use. If you want to modify or distribute it, or use it commercially (either internally or for customer functions) you need to purchase a [Noncommercial, internal use, or SMB license](#license). \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### What if it can't find my chrome?\n\nSee this useful [issue](https://github.com/c9fe/22120/issues/68).\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### What's the roadmap?\n\n- Full text search ✅\n- Library server to serve archive publicly.\n- Distributed p2p web browser on IPFS\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### What about streaming content?\n\nThe following are probably hard (and I haven't thought much about):\n\n- Streaming content (audio, video)\n- \"Impure\" request response pairs (such as if you call GET /endpoint 1 time you get \"A\", if you call it a second time you get \"AA\", and other examples like this).\n- WebSockets (how to capture and replay that faithfully?)\n\nProbably some way to do this tho.\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Can I black list domains to not archive them?\n\nYes! Put any domains into `22120-arc/no.json`\\*, eg:\n\n```json\n[\n  \"*.horribleplantations.com\",\n  \"*.cactusfernfurniture.com\",\n  \"*.gustymeadows.com\",\n  \"*.nytimes.com\",\n  \"*.cnn.co?\"\n]\n```\n\nWill not cache any resource with a host matching those. Wildcards: \n\n- `*` (0 or more anything) and \n- `?` (0 or 1 anything) \n\n\\**Note: the `no` file is per-archive. `22120-arc` is the archive root of a single archive, and by defualt it is placed in your home directory. But you can change the parent directory for `22120-arc` to have multiple archvies, and each archive requires its own `no` file, if you want a blacklist in that archive.*\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Is there a DEBUG mode for troubleshooting?\n\nYes, just make sure you set an environment variable called `DEBUG_22120` to anything non empty.\n\nSo for example in posix systems:\n\n```bash\nexport DEBUG_22120=True\n```\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Can I version the archive?\n\nYes! But you need to use `git` for versioning. Just initiate a git repo in your archive repository. And when you want to save a snapshot, make a new git commit.\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Can I change the archive path?\n\nYes, there's a control for changing the archive path in the control page: http://localhost:22120\n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n### Can I change this other thing?\n\nThere's a few command line arguments. You'll see the format printed as the first printed line when you start the program.\n\nFor other things you can examine the source code. \n\n<p align=right><small><a href=#toc>Top</a></small></p>\n\n"
  },
  {
    "path": "docs/SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nUse this section to tell people about which versions of your project are\ncurrently being supported with security updates.\n\n| Version | Supported          |\n| ------- | ------------------ |\n| Latest  | :white_check_mark: |\n\n\n## Reporting a Vulnerability\n\nTo report a vulnerability, contact: cris@dosycorp.com\n\nTo view previous responsible disclosure vulnerability reports, mediation write ups, notes and other information, please visit the [Dosyago Responsible Dislcousre Center](https://github.com/dosyago/vulnerability-reports)\n"
  },
  {
    "path": "docs/features.md",
    "content": "Cool Possible Feature Ideas\n\n- might be nice to have historical documents indexed as well. For example. Every time we reload a page, we could add a new copy to the index, if it's different...or we could add a new copy if it's been more than X time since the last time we added it. So 1 day , or 1 week. Then we show all results in search (maybe in an expander under the main URL, like \"historical URL\". So you can find a result that was on front page of HN 1 year ago or 3 weeks ago, even if you revisit and reindex HN every day.\n\n"
  },
  {
    "path": "docs/issues",
    "content": "- ndx index seems to lose documents.\n  - e.g.\n  1. visit goog:hell\n  2. visit top link: wiki - hell\n  3. visit hellomagainze.com\n  4. search hell\n  5. see results: goog/hell, wiki/hell, hellomag\n  6. reload wiki - hell\n  7. search hell\n  8. see results: wiki/hell, hellomag\n  - WHERE THE HELL DID goog/hell go? \n\n"
  },
  {
    "path": "docs/todo",
    "content": "- complete snippet generation\n  - sometimes we are not getting any segments. In that case we should just show the first part of the file. \n  - improve trigram segmenter: lower max segment length, increase fore and aft context\n- Index.json is randomly getting clobbered sometimes. Investigate and fix. Important because this breaks the whole archive.\n  - No idea what's causing this after an small investigation. But I've added a log on saveIndex to see when it writes.\n- publish button\n  - way to selectively add (bookmark mode) \n  - way to remove (all modes) items from index\n- save trigram index to disk\n- let's not reindex unless we have changed contentSignature\n- let's not write FTS indexes unless we have changed them since last time (UpdatedKeys)\n- result paging\n- We need to not open other localhosts if we already have one open\n- We need to reload on localhost 22120 if we open with that\n  - throttle how often this can occur per URL\n- search improvements\n  - use different min score options for different sources (noticed URL not match meghan highlight for hello mag even tho query got megan and did match and highlight queen in url)\n  - get snippets earlier (before rendering in lib server) and use to add to signal\n  - if we have multiple query terms (multiple determined by some form of tokenization) then try to show all terms present in the snippet. even tho one term may be higher scoring. Should we do multiple passes of ukkonen distance one for whole query and one for each term? This will be easier / faster with trigrams I guess. Basically we want snippet to be a relevant summary that provides signal.\n  - Another way to improve snippet highlight is to 'revert back' the highlighted text, and calculate their match/ukkonen on the query term. So e.g. if we get q:'israle beverly', hl:['beverly', 'beverly'], it's good overlap, but if we get hl:['is it really'] even tho that might score ok for israle, it's not a good match. so can we 'score that back' if we go match('is it really', 'israel') and see it is low, so we exclude it?\n  - try an exact match on the query term if possible for highlight. first one.\n  - we could also add signal from the highlighting to just in time alter the order (e.g. 'hell wiki' search brings google search to top rank, but the Hell wikipedia page has more highlight visible)\n  - Create instant search (or at least instant queries (so search over previous queries -- not results necessarily))\n  - an error in Full text search can corrupt the index and make it unrecoverable...we need to guard against this\n    - this is still happening. sometimes the index is not saved, even on a normal error free restart. unknown why. \n"
  },
  {
    "path": "eslint.config.js",
    "content": "import globals from \"globals\";\nimport pluginJs from \"@eslint/js\";\n\n\nexport default [\n  {languageOptions: { globals: globals.browser }},\n  pluginJs.configs.recommended,\n];"
  },
  {
    "path": "exec.js",
    "content": "import path from 'path';\nimport {execSync} from 'child_process';\n\nconst runPath = path.resolve(process.argv[2]);\nexecSync(`\"${runPath}\"`,{stdio:'inherit'});\n"
  },
  {
    "path": "global-run.cjs",
    "content": "#!/usr/bin/env node\n\nconst os = require('os');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\n\nif (!fs.existsSync(path.join(process.cwd(), 'node_modules'))) {\n  spawn('npm', ['i'], { stdio: 'inherit' });\n}\n\n// Getting the total system memory\nconst totalMemory = os.totalmem();\n\n// Allocating 90% of the total memory\nconst memoryAllocation = Math.floor((totalMemory / (1024 * 1024)) * 0.8); // Converted bytes to MB and took 90% of it\n\nconsole.log(`Index can use up to: ${memoryAllocation}MB RAM`);\n\n// Running the application\nspawn('node', [`--max-old-space-size=${memoryAllocation}`, path.resolve(__dirname, 'build', 'global', 'downloadnet.cjs')], { stdio: 'inherit' });\n\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"downloadnet\",\n  \"version\": \"4.5.2\",\n  \"type\": \"module\",\n  \"description\": \"Library server and an archivist browser controller.\",\n  \"main\": \"global-run.cjs\",\n  \"module\": \"build/esm/downloadnet.mjs\",\n  \"bin\": {\n    \"dn\": \"global-run.cjs\"\n  },\n  \"scripts\": {\n    \"start\": \"node --max-old-space-size=4096 src/app.js\",\n    \"build\": \"node exec.js \\\"./scripts/build_only.sh\\\"\",\n    \"parcel\": \"node exec.js \\\"./scripts/parcel.sh\\\"\",\n    \"clean\": \"node exec.js \\\"./scripts/clean.sh\\\"\",\n    \"test\": \"node --watch src/app.js\",\n    \"inspect\": \"node --inspect-brk=127.0.0.1:9999 src/app.js\",\n    \"save\": \"node src/app.js DownloadNet save\",\n    \"serve\": \"node src/app.js DownloadNet serve\",\n    \"lint\": \"watch -n 5 npx eslint .\",\n    \"test-hl\": \"node src/highlighter.js\",\n    \"prepublishOnly\": \"npm run build\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dosyago/DownloadNet.git\"\n  },\n  \"keywords\": [\n    \"archivist\",\n    \"library\"\n  ],\n  \"author\": \"@dosy\",\n  \"bugs\": {\n    \"url\": \"https://github.com/dosyago/DownloadNet/issues\"\n  },\n  \"homepage\": \"https://github.com/dosyago/DownloadNet#readme\",\n  \"dependencies\": {\n    \"@667/ps-list\": \"latest\",\n    \"@dosyago/rainsum\": \"latest\",\n    \"chalk\": \"latest\",\n    \"chrome-launcher\": \"latest\",\n    \"express\": \"latest\",\n    \"flexsearch\": \"latest\",\n    \"fz-search\": \"latest\",\n    \"inquirer\": \"latest\",\n    \"natural\": \"latest\",\n    \"ndx\": \"^1.0.2\",\n    \"ndx-query\": \"^1.0.1\",\n    \"ndx-serializable\": \"^1.0.0\",\n    \"ukkonen\": \"latest\",\n    \"ws\": \"latest\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"latest\",\n    \"esbuild\": \"latest\",\n    \"eslint\": \"latest\",\n    \"globals\": \"latest\",\n    \"postject\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "public/find_cleaned_duplicates.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport child_process from 'node:child_process';\n\nimport {\n  loadPref,\n  cache_file,\n  index_file,\n} from '../src/args.js';\n\nconst CLEAN = true;\nconst CONCURRENT = 7;\nconst sleep = ms => new Promise(res => setTimeout(res, ms));\nconst problems = new Map();\nlet cleaning = false;\nlet made = false;\n\nprocess.on('exit', cleanup);\nprocess.on('SIGINT', cleanup);\nprocess.on('SIGTERM', cleanup);\nprocess.on('SIGHUP', cleanup);\nprocess.on('SIGUSR2', cleanup);\nprocess.on('beforeExit', cleanup);\n\nconsole.log({Pref:loadPref(), cache_file: cache_file(), index_file: index_file()});\nmake();\n\nasync function make() {\n  const indexFile = fs.readFileSync(index_file()).toString();\n  JSON.parse(indexFile).map(([key, value]) => {\n    if ( typeof key === \"number\" ) return;\n    if ( key.startsWith('ndx') ) return;\n    if ( value.title === undefined ) {\n      console.log('no title property', {key, value});\n    }\n    const url = key;\n    const title = value.title.toLocaleLowerCase();\n    if ( title.length === 0 || title.includes('404') || title.includes('not found') ) {\n      if ( problems.has(url) ) {\n        console.log('Found duplicate', url, title, problems.get(url));\n      }\n      const prob = {title, dupes:[], dupe:false};\n      problems.set(url, prob);\n      const cleaned1 = clean(url);\n      if ( problems.has(cleaned1) ) {\n        console.log(`Found duplicate`, {url, title, cleaned1, dupeEntry:problems.get(cleaned1)});\n        prob.dupe = true;\n        prob.dupes.push(cleaned1);\n        url !== cleaned1 && (problems.delete(cleaned1), prob.diff = true);\n      }\n      const cleaned2 = clean2(url);\n      if ( problems.has(cleaned2) ) {\n        console.log(`Found duplicate`, {url, title, cleaned2, dupeEntry: problems.get(cleaned2)});\n        prob.dupe = true;\n        prob.dupes.push(cleaned2);\n        url !== cleaned2 && (problems.delete(cleaned2), prob.diff = true);\n      }\n    }\n  });\n\n  made = true;\n\n  cleanup();\n}\n\nfunction cleanup() {\n  if ( cleaning ) return;\n  if ( ! made ) return;\n  cleaning = true;\n  console.log('cleanup running');\n  const outData = [...problems.entries()].filter(([key, {dupe}]) => dupe);\n  outData.sort(([a], [b]) => a.localeCompare(b));\n  fs.writeFileSync(\n    path.resolve('.', 'url-cleaned-dupes.json'), \n    JSON.stringify(outData, null, 2)\n  );\n  const {size:bytesWritten} = fs.statSync(\n    path.resolve('.', 'url-cleaned-dupes.json'), \n    {bigint: true}\n  );\n  console.log(`Wrote ${outData.length} dupe urls in ${bytesWritten} bytes.`);\n  process.exit(0);\n}\n\nfunction clean(urlString) {\n  const url = new URL(urlString);\n  if ( url.hash.startsWith('#!') || url.hostname.includes('google.com') || url.hostname.includes('80s.nyc') ) {\n  } else {\n    url.hash = '';\n  }\n  for ( const [key, value] of url.searchParams ) {\n    if ( key.startsWith('utm_') ) {\n      url.searchParams.delete(key);\n    }\n  }\n  url.pathname = url.pathname.replace(/\\/$/, '');\n  url.protocol = 'https:';\n  url.pathname = url.pathname.replace(/(\\.htm.?|\\.php|\\.asp.?)$/, '');\n  if ( url.hostname.startsWith('www.') ) {\n    url.hostname = url.hostname.replace(/^www./, '');\n  }\n  const key = url.toString();\n  return key;\n}\n\nfunction clean2(urlString) {\n  const url = new URL(urlString);\n  url.pathname = ''; \n  return url.toString();\n}\n\nfunction curlCommand(url) {\n  return `curl -k -L -s -o /dev/null -w '%{url_effective}' ${JSON.stringify(url)} \\\n    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \\\n    -H 'Accept-Language: en,en-US;q=0.9,zh-TW;q=0.8,zh-CN;q=0.7,zh;q=0.6,ja;q=0.5' \\\n    -H 'Cache-Control: no-cache' \\\n    -H 'Connection: keep-alive' \\\n    -H 'DNT: 1' \\\n    -H 'Pragma: no-cache' \\\n    -H 'Sec-Fetch-Dest: document' \\\n    -H 'Sec-Fetch-Mode: navigate' \\\n    -H 'Sec-Fetch-Site: none' \\\n    -H 'Sec-Fetch-User: ?1' \\\n    -H 'Upgrade-Insecure-Requests: 1' \\\n    -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' \\\n    -H 'sec-ch-ua: \"Chromium\";v=\"104\", \" Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"104\"' \\\n    -H 'sec-ch-ua-mobile: ?0' \\\n    -H 'sec-ch-ua-platform: \"macOS\"' \\\n    --compressed ;\n  `;\n}\n"
  },
  {
    "path": "public/find_crawlable.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport child_process from 'node:child_process';\n\nconst CLEAN = false;\nconst CONCURRENT = 7;\nconst sleep = ms => new Promise(res => setTimeout(res, ms));\nconst entries = [];\nlet cleaning = false;\n\nprocess.on('exit', cleanup);\nprocess.on('SIGINT', cleanup);\nprocess.on('SIGTERM', cleanup);\nprocess.on('SIGHUP', cleanup);\nprocess.on('SIGUSR2', cleanup);\nprocess.on('beforeExit', cleanup);\n\nmake();\n\nasync function make() {\n  const titlesFile = fs.readFileSync(path.resolve('.', 'topTitles.json')).toString();\n  const titles = new Map(JSON.parse(titlesFile).map(([url, title]) => [url, {url,title}]));\n  titles.forEach(({url,title}) => {\n    if ( title.length === 0 && url.startsWith('https:') && !url.endsWith('.pdf') ) {\n      entries.push(url);\n    }\n  });\n\n  cleanup();\n}\n\nfunction cleanup() {\n  if ( cleaning ) return;\n  cleaning = true;\n  console.log('cleanup running');\n  fs.writeFileSync(\n    path.resolve('.', 'recrawl-https-3.json'), \n    JSON.stringify(entries, null, 2)\n  );\n  console.log(`Wrote recrawlable urls`);\n  process.exit(0);\n}\n\nfunction clean(urlString) {\n  const url = new URL(urlString);\n  if ( url.hash.startsWith('#!') || url.hostname.includes('google.com') || url.hostname.includes('80s.nyc') ) {\n  } else {\n    url.hash = '';\n  }\n  for ( const [key, value] of url.searchParams ) {\n    if ( key.startsWith('utm_') ) {\n      url.searchParams.delete(key);\n    }\n  }\n  url.pathname = url.pathname.replace(/\\/$/, '');\n  url.protocol = 'https:';\n  url.pathname = url.pathname.replace(/(\\.htm.?|\\.php)$/, '');\n  if ( url.hostname.startsWith('www.') ) {\n    url.hostname = url.hostname.replace(/^www./, '');\n  }\n  const key = url.toString();\n  return key;\n}\n\nfunction clean2(urlString) {\n  const url = new URL(urlString);\n  url.pathname = ''; \n  return url.toString();\n}\n\nfunction curlCommand(url) {\n  return `curl -k -L -s -o /dev/null -w '%{url_effective}' ${JSON.stringify(url)} \\\n    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \\\n    -H 'Accept-Language: en,en-US;q=0.9,zh-TW;q=0.8,zh-CN;q=0.7,zh;q=0.6,ja;q=0.5' \\\n    -H 'Cache-Control: no-cache' \\\n    -H 'Connection: keep-alive' \\\n    -H 'DNT: 1' \\\n    -H 'Pragma: no-cache' \\\n    -H 'Sec-Fetch-Dest: document' \\\n    -H 'Sec-Fetch-Mode: navigate' \\\n    -H 'Sec-Fetch-Site: none' \\\n    -H 'Sec-Fetch-User: ?1' \\\n    -H 'Upgrade-Insecure-Requests: 1' \\\n    -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' \\\n    -H 'sec-ch-ua: \"Chromium\";v=\"104\", \" Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"104\"' \\\n    -H 'sec-ch-ua-mobile: ?0' \\\n    -H 'sec-ch-ua-platform: \"macOS\"' \\\n    --compressed ;\n  `;\n}\n"
  },
  {
    "path": "public/injection.js",
    "content": "import {DEBUG as debug} from '../src/common.js';\n\nconst DEBUG = debug || false;\n\nexport function getInjection({sessionId}) {\n  // Notes:\n    // say() function\n      // why aliased? Resistant to page overwriting\n      // just a precaution as we are already in an isolated world here, but this makes\n      // this script more portable if it were introduced globally as well as robust \n      // against API or behaviour changes of the browser or its remote debugging protocol\n      // in future\n  return `\n    {\n      const X = 1;\n      const DEBUG = ${JSON.stringify(DEBUG, null, 2)};\n      const MIN_CHECK_TEXT = 3000;  // min time between checking documentElement.innerText\n      const MIN_NOTIFY = 5000;      // min time between telling controller text maybe changed\n      const MAX_NOTIFICATIONS = 13; // max times we will tell controller text maybe changed\n      const OBSERVER_OPTS = {\n        subtree: true,\n        childList: true,\n        characterData: true\n      };\n      const Top = globalThis.top;\n      let lastInnerText;\n\n      if ( Top === globalThis ) {\n        const ConsoleInfo = console.info.bind(console);\n        const JSONStringify = JSON.stringify.bind(JSON);\n        const TITLE_CHANGES = 10;\n        const INITIAL_CHECK_TIME = 500;\n        const TIME_MULTIPLIER = Math.E;\n        const sessionId = \"${sessionId}\";\n        const sleep = ms => new Promise(res => setTimeout(res, ms));\n        const handler = throttle(handleFrameMessage, MIN_NOTIFY);\n        let count = 0;\n\n        installTop();\n\n        async function installTop() {\n          console.log(\"Installing in top frame...\");\n          self.startUrl = location.href;\n          say({install: { sessionId, startUrl }});\n          await sleep(1000);\n          beginTitleChecks();\n          // start monitoring text changes from 30 seconds after load\n          setTimeout(() => beginTextNotifications(), 30000);\n          console.log(\"Installed.\");\n        }\n\n        function beginTitleChecks() {\n          let lastTitle = null;\n          let checker;\n          let timeToNextCheck = INITIAL_CHECK_TIME;\n          let changesLogged = 0;\n\n          check();\n          console.log('Begun logging title changes.');\n\n          function check() {\n            clearTimeout(checker);\n            const currentTitle = document.title; \n            if ( lastTitle !== currentTitle ) {\n              say({titleChange: {lastTitle, currentTitle, url: location.href, sessionId}});\n              lastTitle = currentTitle;\n              changesLogged++;\n            } else {\n              // increase check time if there's no change\n              timeToNextCheck *= TIME_MULTIPLIER;\n            }\n            if ( changesLogged < TITLE_CHANGES ) {\n              checker = setTimeout(check, timeToNextCheck);\n            } else {\n              console.log('Finished logging title changes.'); \n            }\n          }\n        }\n\n        function say(thing) {\n          ConsoleInfo(JSONStringify(thing));\n        }\n\n        function beginTextNotifications() {\n          // listen for {textChange:true} messages\n          // throttle them\n          // on leading throttle edge send message to controller with \n          // console.info(JSON.stringify({textChange:...}));\n          self.addEventListener('message', messageParser);\n\n          console.log('Begun notifying of text changes.');\n\n          function messageParser({data, origin}) {\n            let source;\n            try {\n              ({source} = data.frameTextChangeNotification);\n              if ( count > MAX_NOTIFICATIONS ) {\n                self.removeEventListener('message', messageParser);\n                return;\n              }\n              count++;\n              handler({textChange:{source}});\n            } catch(e) {\n              DEBUG.verboseSlow && console.warn('could not parse message', data, e);\n            }\n          }\n        }\n\n        function handleFrameMessage({textChange}) {\n          const {source} = textChange;\n          console.log('Telling controller that text changed');\n          say({textChange:{source, sessionId, count}});\n        }\n      } \n\n      beginTextMutationChecks();\n\n      function beginTextMutationChecks() {\n        // create mutation observer for text\n        // throttle output\n\n        const observer = new MutationObserver(throttle(check, MIN_CHECK_TEXT));\n        observer.observe(document.documentElement || document, OBSERVER_OPTS);\n\n        console.log('Begun observing text changes.');\n        \n        function check() {\n          console.log('check');\n          const textMutated = document.documentElement.innerText !== lastInnerText;\n          if ( textMutated ) {\n            DEBUG.verboseSlow && console.log('Text changed');\n            lastInnerText = document.documentElement.innerText;\n            Top.postMessage({frameTextChangeNotification:{source:location.href}}, '*');\n          }\n        }\n      }\n\n      // javascript throttle function\n        // source: https://stackoverflow.com/a/59378445 \n        /*\n        function throttle(func, timeFrame) {\n          var lastTime = 0;\n          return function (...args) {\n            var now = new Date();\n            if (now - lastTime >= timeFrame) {\n              func.apply(this, args);\n              lastTime = now;\n            }\n          };\n        }\n        */\n\n      // alternate throttle function with trailing edge call\n        // source: https://stackoverflow.com/a/27078401\n        ///*\n        // Notes\n          // Returns a function, that, when invoked, will only be triggered at most once\n          // during a given window of time. Normally, the throttled function will run\n          // as much as it can, without ever going more than once per \\`wait\\` duration;\n          // but if you'd like to disable the execution on the leading edge, pass\n          // \\`{leading: false}\\`. To disable execution on the trailing edge, ditto.\n\t\t\t\tfunction throttle(func, wait, options) {\n\t\t\t\t\tvar context, args, result;\n\t\t\t\t\tvar timeout = null;\n\t\t\t\t\tvar previous = 0;\n\t\t\t\t\tif (!options) options = {};\n\t\t\t\t\tvar later = function() {\n\t\t\t\t\t\tprevious = options.leading === false ? 0 : Date.now();\n\t\t\t\t\t\ttimeout = null;\n\t\t\t\t\t\tresult = func.apply(context, args);\n\t\t\t\t\t\tif (!timeout) context = args = null;\n\t\t\t\t\t};\n\t\t\t\t\treturn function() {\n\t\t\t\t\t\tvar now = Date.now();\n\t\t\t\t\t\tif (!previous && options.leading === false) previous = now;\n\t\t\t\t\t\tvar remaining = wait - (now - previous);\n\t\t\t\t\t\tcontext = this;\n\t\t\t\t\t\targs = arguments;\n\t\t\t\t\t\tif (remaining <= 0 || remaining > wait) {\n\t\t\t\t\t\t\tif (timeout) {\n\t\t\t\t\t\t\t\tclearTimeout(timeout);\n\t\t\t\t\t\t\t\ttimeout = null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tprevious = now;\n\t\t\t\t\t\t\tresult = func.apply(context, args);\n\t\t\t\t\t\t\tif (!timeout) context = args = null;\n\t\t\t\t\t\t} else if (!timeout && options.trailing !== false) {\n\t\t\t\t\t\t\ttimeout = setTimeout(later, remaining);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn result;\n\t\t\t\t\t};\n\t\t\t\t}\n        //*/\n    }\n  `;\n}\n"
  },
  {
    "path": "public/library/README.md",
    "content": "# ALT Default storage directory for library\n\nRemove `public/library/http*` and `public/library/cache.json` from `.gitignore` if you forked this repo and want to commit your library using git.\n\n## Clearing your cache\n\nTo clear everything, delete all directories that start with `http` or `https` and delete cache.json\n\nTo clear only stuff from domains you don't want, delete all directories you don't want that start with `http` or `https` and DON'T delete cache.json\n\n"
  },
  {
    "path": "public/make_top.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport child_process from 'node:child_process';\n\nconst CLEAN = false;\nconst CONCURRENT = 7;\nconst sleep = ms => new Promise(res => setTimeout(res, ms));\nconst entries = [];\nconst counted = new Set();\nconst errors = new Map();\nlet counts;\nlet cleaning = false;\n\nprocess.on('exit', cleanup);\nprocess.on('SIGINT', cleanup);\nprocess.on('SIGTERM', cleanup);\nprocess.on('SIGHUP', cleanup);\nprocess.on('SIGUSR2', cleanup);\nprocess.on('beforeExit', cleanup);\n\nmake();\n\nasync function make() {\n  const titlesFile = fs.readFileSync(path.resolve('.', 'topTitles.json')).toString();\n  const titles = new Map(JSON.parse(titlesFile).map(([url, title]) => [url, {url,title}]));\n  if ( CLEAN ) {\n    for ( const [url, obj] of titles ) {\n      const k1 = clean(url);\n      const k2 = clean2(url);\n      if ( !titles.has(k1) ) {\n        titles.set(k1, obj);\n      }\n      if ( !titles.has(k2) ) {\n        titles.set(k2, obj);\n      }\n    }\n  }\n  const remainingFile =  fs.readFileSync(path.resolve('.', 'remainingFile.json')).toString();\n  const remainingSet = new Set(JSON.parse(remainingFile));\n  const countsFile = fs.readFileSync(path.resolve('.', 'ran-counts.json')).toString();\n  counts = new Map(JSON.parse(countsFile).filter(([url, count]) => remainingSet.has(url)));\n  let current = 0;\n  for ( const [url, count] of counts ) {\n    let title;\n    let realUrl;\n    if ( titles.has(url) ) {\n      ({title} = titles.get(url));\n      entries.push({\n        url, \n        title, \n        count,\n      });\n      counted.add(url);\n    } else {\n      console.log(`Curl call for ${url} in progress...`);\n      let notifyCurlComplete;\n      const curlCall = new Promise(res => notifyCurlComplete = res);\n      do {\n        await sleep(1000);\n      } while ( current >= CONCURRENT );\n      child_process.exec(curlCommand(url), (err, stdout, stderr) => {\n        if ( ! err && (!stderr || stderr.length == 0)) {\n          realUrl = stdout; \n          if ( titles.has(realUrl) ) {\n            ({title} = titles.get(realUrl));\n            entries.push({\n              url, \n              realUrl,\n              title, \n              count,\n            });\n            counted.add(url);\n          }\n        } else {\n          console.log(`Error on curl for ${url}`, {err, stderr});\n          errors.set(url, {err, stderr});\n        }\n        console.log(`Curl call for ${url} complete!`);\n        notifyCurlComplete();\n      });\n      current += 1;\n      curlCall.then(() => current -= 1);\n    }\n  }\n  cleanup();\n}\n\nasync function make_v2() {\n  const titlesFile = fs.readFileSync(path.resolve('.', 'topTitles.json')).toString();\n  const titles = new Map(JSON.parse(titlesFile).map(([url, title]) => [url, {url,title}]));\n  if ( CLEAN ) {\n    for ( const [url, obj] of titles ) {\n      const k1 = clean(url);\n      const k2 = clean2(url);\n      if ( !titles.has(k1) ) {\n        titles.set(k1, obj);\n      }\n      if ( !titles.has(k2) ) {\n        titles.set(k2, obj);\n      }\n    }\n  }\n  const countsFile = fs.readFileSync(path.resolve('.', 'ran-counts.json')).toString();\n  counts = new Map(JSON.parse(countsFile));\n  let current = 0;\n  for ( const [url, count] of counts ) {\n    let title;\n    let realUrl;\n    if ( titles.has(url) ) {\n      ({title} = titles.get(url));\n      entries.push({\n        url, \n        title, \n        count,\n      });\n      counted.add(url);\n    } else {\n      console.log(`Curl call for ${url} in progress...`);\n      let notifyCurlComplete;\n      const curlCall = new Promise(res => notifyCurlComplete = res);\n      do {\n        await sleep(250);\n      } while ( current >= CONCURRENT );\n      child_process.exec(curlCommand(url), (err, stdout, stderr) => {\n        if ( ! err && (!stderr || stderr.length == 0)) {\n          realUrl = stdout; \n          if ( titles.has(realUrl) ) {\n            ({title} = titles.get(realUrl));\n            entries.push({\n              url, \n              realUrl,\n              title, \n              count,\n            });\n            counted.add(url);\n          }\n        } else {\n          console.log(`Error on curl for ${url}`, {err, stderr});\n          errors.set(url, {err, stderr});\n        }\n        console.log(`Curl call for ${url} complete!`);\n        notifyCurlComplete();\n      });\n      current += 1;\n      curlCall.then(() => current -= 1);\n    }\n  }\n  cleanup();\n}\n\nfunction cleanup() {\n  if ( cleaning ) return;\n  cleaning = true;\n  console.log('cleanup running');\n  if ( errors.size ) {\n    fs.writeFileSync(\n      path.resolve('.', 'errorLinks4.json'),\n      JSON.stringify([...errors.keys()], null, 2)\n    );\n    console.log(`Wrote errors`);\n  }\n  if ( counted.size !== counts.size ) {\n    counted.forEach(url => counts.delete(url)); \n    fs.writeFileSync(\n      path.resolve('.', 'noTitleFound4.json'),\n      JSON.stringify([...counts.keys()], null, 2)\n    )\n    console.log(`Wrote noTitleFound`);\n  }\n  fs.writeFileSync(\n    path.resolve('.', 'topFrontPageLinksWithCounts4.json'), \n    JSON.stringify(entries, null, 2)\n  );\n  console.log(`Wrote top links with counts`);\n  process.exit(0);\n}\n\nasync function make_v1() {\n  const titlesFile = fs.readFileSync(path.resolve('.', 'topTitles.json')).toString();\n  const titles = new Map(JSON.parse(titlesFile).map(([url, title]) => [clean(url), {url,title}]));\n  const countsFile = fs.readFileSync(path.resolve('.', 'counts.json')).toString();\n  const counts = new Map(JSON.parse(countsFile).map(([url, count]) => [clean(url), count]));\n  for ( const [key, count] of counts ) {\n    counts.set(clean2(key), count);\n  }\n  const entries = [];\n  for ( const [key, {url,title}] of titles ) {\n    entries.push({\n      url, title, \n      count: counts.get(key) || \n        counts.get(url) || \n        counts.get(clean2(key)) || \n        console.log(`No count found for`, {key, url, title, c2key: clean2(key)})\n    });\n  }\n  fs.writeFileSync(\n    path.resolve('.', 'topFrontPageLinks.json'), \n    JSON.stringify(entries, null, 2)\n  );\n}\n\nfunction clean(urlString) {\n  const url = new URL(urlString);\n  if ( url.hash.startsWith('#!') || url.hostname.includes('google.com') || url.hostname.includes('80s.nyc') ) {\n  } else {\n    url.hash = '';\n  }\n  for ( const [key, value] of url.searchParams ) {\n    if ( key.startsWith('utm_') ) {\n      url.searchParams.delete(key);\n    }\n  }\n  url.pathname = url.pathname.replace(/\\/$/, '');\n  url.protocol = 'https:';\n  url.pathname = url.pathname.replace(/(\\.htm.?|\\.php)$/, '');\n  if ( url.hostname.startsWith('www.') ) {\n    url.hostname = url.hostname.replace(/^www./, '');\n  }\n  const key = url.toString();\n  return key;\n}\n\nfunction clean2(urlString) {\n  const url = new URL(urlString);\n  url.pathname = ''; \n  return url.toString();\n}\n\nfunction curlCommand(url) {\n  return `curl -k -L -s -o /dev/null -w '%{url_effective}' ${JSON.stringify(url)} \\\n    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \\\n    -H 'Accept-Language: en,en-US;q=0.9,zh-TW;q=0.8,zh-CN;q=0.7,zh;q=0.6,ja;q=0.5' \\\n    -H 'Cache-Control: no-cache' \\\n    -H 'Connection: keep-alive' \\\n    -H 'DNT: 1' \\\n    -H 'Pragma: no-cache' \\\n    -H 'Sec-Fetch-Dest: document' \\\n    -H 'Sec-Fetch-Mode: navigate' \\\n    -H 'Sec-Fetch-Site: none' \\\n    -H 'Sec-Fetch-User: ?1' \\\n    -H 'Upgrade-Insecure-Requests: 1' \\\n    -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' \\\n    -H 'sec-ch-ua: \"Chromium\";v=\"104\", \" Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"104\"' \\\n    -H 'sec-ch-ua-mobile: ?0' \\\n    -H 'sec-ch-ua-platform: \"macOS\"' \\\n    --compressed ;\n  `;\n}\n"
  },
  {
    "path": "public/old-index.html",
    "content": "<!DOCTYPE html>\n<meta charset=utf-8>\n<title>Your Personal Search Engine and Archive</title>\n<link rel=stylesheet href=style.css>\n<header>\n  <h1><a href=/>DownloadNet</a> &mdash; Personal Web Search and Archive</h1>\n</header>\n<p>\n  View <a href=/archive_index.html>your index</a>\n</p>\n<!--\n<form method=POST action=/crawl>\n  <fieldset>\n    <legend>Crawl and Index</legend>\n    <p>\n      Crawl and index a list of links. \n      <br>\n      <small>This will open 1 link at a time, and index it when it has loaded.</small>\n    <p>\n      <label>\n        Links\n        <br>\n        <textarea class=long name=links>\n          https://cnn.com\n          https://bloomberg.com\n          https://microsoft.com\n          https://dosyago.com\n          https://intel.com\n        </textarea>\n        <br>\n        <small>List format is 1 link per line.</small>\n      </label>\n    </p>\n    <details open>\n      <summary>Advanced settings</summary>\n      <p>\n        <label>\n          Timeout\n          <br>\n          <input required name=timeout\n            type=number min=1 max=300 value=3.6 step=0.1> <span class=units>seconds</span>\n          <br>\n          <small>Seconds to wait for each page to load before indexing.</small>\n        </label>\n      <p>\n      <label>\n        Depth\n        <br>\n        <input required name=depth \n          type=number min=1 max=20 value=1 step=1> <span class=units>clicks</span>\n      </label>\n      <br>\n      <section class=small>\n        <strong>Value guide</strong>\n        <ol>\n          <li>Only each link.\n          <li>Plus anything 1 click from the link.\n          <li>Plus anything 2 clicks from the link.\n        </ol>\n        <em>And so on&hellip;</em>\n      </section>\n      <p>\n        <label>\n          Min Page Crawl Time\n          <br>\n          <input name=minPageCrawlTime\n            type=number min=1 max=60 value=20> <span class=units>seconds</span>\n          <br>\n          <small>Seconds to wait for each page to load before indexing.</small>\n        </label>\n      <p>\n      <p>\n        <label>\n          Max Page Crawl Time\n          <br>\n          <input name=maxPageCrawlTime\n            type=number min=3 max=120 value=30> <span class=units>seconds</span>\n          <br>\n          <small>Max time to allow for each page.</small>\n        </label>\n      <p>\n      <p>\n        <label>\n          Batch size\n          <br>\n          <input name=batchSize\n            type=number min=1 max=32 value=2> <span class=units>tabs</span>\n          <br>\n          <small>Number of concurrent tabs.</small>\n        </label>\n      <p>\n      <p>\n        <label>\n          <input name=saveToFile\n            type=checkbox checked>\n            Save the harvested URLs to a file\n        </label>\n      <p>\n      <p>\n        <label>\n          <span class=text>Program to run on every page</span>\n          <br>\n          <textarea class=long rows=9 name=program>\n            if ( ! State.titles ) {\n              State.titles = new Map();\n              State.onExit.addHandler(() => {\n                fs.writeFileSync(\n                  path.resolve('.', `titles-${(new Date).toISOString()}.txt`), \n                  JSON.stringify([...State.titles.entries()], null, 2) + '\\n'\n                );\n              });\n            }\n            const {result:{value:data}} = await send(\"Runtime.evaluate\", \n              {\n                expression: `(function () {\n                  return {\n                    url: document.location.href,\n                    title: document.title,\n                  };\n                }())`,\n                returnByValue: true\n              }, \n              sessionId\n            );\n            State.titles.set(data.url, data.title);\n            console.log(`Saved ${State.titles.size} titles`);\n          </textarea>\n        </label>\n      </p>\n    </details>\n    <p>\n      <button>Crawl</button>\n      <script>\n        {\n          const button = document.currentScript.previousElementSibling;\n          let disabled = false;\n          button.addEventListener('click', click => {\n            if ( disabled ) return click.preventDefault(); \n            disabled = true;\n            setTimeout(() => button.disabled = true, 0);\n          });\n        }\n      </script>\n  </fieldset>\n</form>\n-->\n<form method=GET action=/search>\n  <fieldset class=search>\n    <legend>Search your archive</legend>\n    <input autofocus class=search type=search name=query placeholder=\"search your library\">\n    <button>Search</button>\n  </fieldset>\n</form>\n<form method=POST action=/mode>\n  <fieldset>\n    <legend>Save or Serve: Mode Control</legend>\n    <p>\n      Control whether pages you browse are <label class=cmd for=save>saved to</label>, or \n      <label class=cmd for=serve>served from</label> your archive\n      <br>\n      <small><em class=caps>Pro-Tip:</em> Serve pages when you're offline, and it will still feel like you're online</small>\n    <p>\n      <label>\n        <input type=radio name=mode value=save id=save>\n        Save\n      </label>\n      <label>\n        <input type=radio name=mode value=serve id=serve>\n        Serve\n      </label>\n      <label>\n        <input type=radio name=mode value=select id=select>\n        Select (<em>Bookmark mode</em>)\n      </label>\n      <output name=notification>\n    <p>\n      <button>Change mode</button>\n    <script>\n      {\n        const form = document.currentScript.closest('form');\n        form.notification.value = \"Getting current mode...\";\n        setTimeout(showCurrentMode, 300);\n\n        async function showCurrentMode() {\n          const mode = await fetch('/mode').then(r => r.text());\n          console.log({mode});\n          if ( ! mode ) {\n            setTimeout(showCurrentMode, 300);\n            return;\n          }\n          form.notification.value = \"\";\n          form.querySelector(`[name=\"mode\"][value=\"${mode}\"]`).checked = true;\n        }\n      }\n    </script>\n  </fieldset>\n</form>\n<form method=POST action=/base_path>\n  <fieldset>\n    <legend id=new_base_path>File system path of archive</legend>\n    <p>\n      Set the path to where your archive folder will go\n      <br>\n      <small>The default is your home directory</small>\n    <p>\n      <label>\n        Base path\n        <input class=long type=text name=base_path placeholder=\"A folder path...\">\n      </label>\n    <p>\n      <button>Change base path</button>\n    <script>\n      {\n        const form = document.currentScript.closest('form');\n        showCurrentLibraryPath();\n\n        form.base_path.onchange = e => {\n          self.target = e.target;\n        }\n        async function showCurrentLibraryPath() {\n          const base_path = await fetch('/base_path').then(r => r.text());\n          form.querySelector(`[name=\"base_path\"]`).value = base_path;\n        }\n      }\n    </script>\n  </fieldset>\n</form>\n<form disabled method=POST action=/publish>\n  <fieldset>\n    <legend>Publish your archive</legend>\n    <p>\n      Publish a search engine from your archive \n      <br>\n      <small>This will generate a server.zip file that you can unzip and run</small>\n    <p>\n      <button disabled>Publish</button>\n  </fieldset>\n</form>\n<p>\n  Notice a bug? <a href=https://github.com/dosyago/DownloadNet/issues>Open an issue!</a>\n</p>\n<footer>\n  <cite>\n    <a rel=author href=https://github.com/dosyago/DownloadNet>DownloadNet GitHub</a>\n  </cite>\n</footer>\n"
  },
  {
    "path": "public/problem_find.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport child_process from 'node:child_process';\n\nimport {\n  loadPref,\n  cache_file,\n  index_file,\n} from '../src/args.js';\n\nconst CLEAN = false;\nconst CONCURRENT = 7;\nconst sleep = ms => new Promise(res => setTimeout(res, ms));\nconst problems = new Map();\nlet cleaning = false;\nlet made = false;\n\nprocess.on('exit', cleanup);\nprocess.on('SIGINT', cleanup);\nprocess.on('SIGTERM', cleanup);\nprocess.on('SIGHUP', cleanup);\nprocess.on('SIGUSR2', cleanup);\nprocess.on('beforeExit', cleanup);\n\nconsole.log({Pref:loadPref(), cache_file: cache_file(), index_file: index_file()});\nmake();\n\nasync function make() {\n  const indexFile = fs.readFileSync(index_file()).toString();\n  JSON.parse(indexFile).map(([key, value]) => {\n    if ( typeof key === \"number\" ) return;\n    if ( key.startsWith('ndx') ) return;\n    if ( value.title === undefined ) {\n      console.log('no title property', {key, value});\n    }\n    const url = key;\n    const title = value.title.toLocaleLowerCase();\n    if ( title.length === 0 || title.includes('404') || title.includes('not found') ) {\n      if ( problems.has(url) ) {\n        console.log('Found duplicate', url, title, problems.get(url));\n      }\n      problems.set(url, title);\n    }\n  });\n\n  made = true;\n\n  cleanup();\n}\n\nfunction cleanup() {\n  if ( cleaning ) return;\n  if ( ! made ) return;\n  cleaning = true;\n  console.log('cleanup running');\n  const outData = [...problems.entries()];\n  fs.writeFileSync(\n    path.resolve('.', 'url-problems.json'), \n    JSON.stringify(outData, null, 2)\n  );\n  const {size:bytesWritten} = fs.statSync(\n    path.resolve('.', 'url-problems.json'), \n    {bigint: true}\n  );\n  console.log(`Wrote ${outData.length} problem urls in ${bytesWritten} bytes.`);\n  process.exit(0);\n}\n\nfunction clean(urlString) {\n  const url = new URL(urlString);\n  if ( url.hash.startsWith('#!') || url.hostname.includes('google.com') || url.hostname.includes('80s.nyc') ) {\n  } else {\n    url.hash = '';\n  }\n  for ( const [key, value] of url.searchParams ) {\n    if ( key.startsWith('utm_') ) {\n      url.searchParams.delete(key);\n    }\n  }\n  url.pathname = url.pathname.replace(/\\/$/, '');\n  url.protocol = 'https:';\n  url.pathname = url.pathname.replace(/(\\.htm.?|\\.php)$/, '');\n  if ( url.hostname.startsWith('www.') ) {\n    url.hostname = url.hostname.replace(/^www./, '');\n  }\n  const key = url.toString();\n  return key;\n}\n\nfunction clean2(urlString) {\n  const url = new URL(urlString);\n  url.pathname = ''; \n  return url.toString();\n}\n\nfunction curlCommand(url) {\n  return `curl -k -L -s -o /dev/null -w '%{url_effective}' ${JSON.stringify(url)} \\\n    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \\\n    -H 'Accept-Language: en,en-US;q=0.9,zh-TW;q=0.8,zh-CN;q=0.7,zh;q=0.6,ja;q=0.5' \\\n    -H 'Cache-Control: no-cache' \\\n    -H 'Connection: keep-alive' \\\n    -H 'DNT: 1' \\\n    -H 'Pragma: no-cache' \\\n    -H 'Sec-Fetch-Dest: document' \\\n    -H 'Sec-Fetch-Mode: navigate' \\\n    -H 'Sec-Fetch-Site: none' \\\n    -H 'Sec-Fetch-User: ?1' \\\n    -H 'Upgrade-Insecure-Requests: 1' \\\n    -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' \\\n    -H 'sec-ch-ua: \"Chromium\";v=\"104\", \" Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"104\"' \\\n    -H 'sec-ch-ua-mobile: ?0' \\\n    -H 'sec-ch-ua-platform: \"macOS\"' \\\n    --compressed ;\n  `;\n}\n"
  },
  {
    "path": "public/redirector.html",
    "content": "<!DOCTYPE html>\n<meta name=\"referrer\" content=\"no-referrer\" />\n<h1>About to index archive and index <code id=url-text></code></h1>\n<script type=module>\n  const url = new URLSearchParams(location.search).get('url');\n  const text = document.querySelector('#url-text');\n  let valid = false;\n  try {\n    new URL(url);\n    valid = true;\n  } catch(e) {\n    console.warn(`URL ${url} is not a valid URL`);\n  }\n\n  if ( valid ) {\n    text.innerText = url;\n    setTimeout(() => {\n      window.location.href = url;\n    }, 1000);\n  }\n</script>\n"
  },
  {
    "path": "public/style.css",
    "content": "/* public/style.css */\n\n/* 1. Modern CSS Reset (Simplified) */\n*, *::before, *::after {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n\nhtml {\n  -webkit-text-size-adjust: 100%;\n  tab-size: 4;\n  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n  line-height: 1.5;\n}\n\nbody {\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n}\n\nimg, picture, video, canvas, svg {\n  display: block;\n  max-width: 100%;\n}\n\ninput, button, textarea, select {\n  font: inherit;\n}\n\nbutton {\n  cursor: pointer;\n}\n\na {\n  text-decoration: none;\n  color: inherit;\n}\n\nul, ol {\n  list-style: none;\n}\n\n/* 2. CSS Custom Properties (Variables) & Theming */\n:root {\n  /* Light Mode (Default) */\n  --font-primary: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n  --font-monospace: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;\n\n  --color-text: #222;\n  --color-text-muted: #555;\n  --color-background: #f8f9fa;\n  --color-surface: #ffffff;\n  --color-primary: #007bff;\n  --color-primary-hover: #0056b3;\n  --color-secondary: #6c757d;\n  --color-border: #dee2e6;\n  --color-accent: #17a2b8;\n  --color-success: #28a745;\n  --color-danger: #dc3545;\n  --color-warning: #ffc107;\n  --color-highlight-bg: #ffe082; /* For search term highlighting */\n\n  --spacing-xs: 0.25rem;\n  --spacing-sm: 0.5rem;\n  --spacing-md: 1rem;\n  --spacing-lg: 1.5rem;\n  --spacing-xl: 2rem;\n\n  --border-radius: 0.375rem;\n  --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --color-text: #e9ecef;\n    --color-text-muted: #adb5bd;\n    --color-background: #121212; /* Slightly off-black for depth */\n    --color-surface: #1e1e1e;   /* For cards, modals, etc. */\n    --color-primary: #0d6efd;\n    --color-primary-hover: #0b5ed7;\n    --color-secondary: #495057;\n    --color-border: #343a40;\n    --color-accent: #20c997;\n    --color-success: #198754;\n    --color-danger: #dc3545;\n    --color-warning: #ffca2c;\n    --color-highlight-bg: #4a3c00; /* Darker highlight for dark mode */\n  }\n}\n\n/* 3. Base & Layout Styles */\nbody {\n  font-family: var(--font-primary);\n  background-color: var(--color-background);\n  color: var(--color-text);\n  display: flex;\n  flex-direction: column;\n  min-height: 100vh;\n}\n\n.container {\n  width: 90%;\n  max-width: 1000px;\n  margin: 0 auto;\n  padding: var(--spacing-lg) var(--spacing-md);\n  flex-grow: 1;\n  display: flex;\n  flex-direction: column;\n}\n\n.site-header {\n  padding-bottom: var(--spacing-md);\n  margin-bottom: var(--spacing-lg);\n  border-bottom: 1px solid var(--color-border);\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  flex-wrap: wrap; /* Allow wrapping on small screens */\n}\n\n.site-header h1 {\n  font-size: 1.75rem;\n  font-weight: 600;\n  margin: 0;\n}\n.site-header h1 a {\n  color: var(--color-primary);\n  transition: color 0.2s ease-in-out;\n}\n.site-header h1 a:hover {\n  color: var(--color-primary-hover);\n}\n\n.main-nav ul {\n  display: flex;\n  gap: var(--spacing-md);\n}\n.main-nav a {\n  color: var(--color-text-muted);\n  font-weight: 500;\n  transition: color 0.2s ease-in-out;\n}\n.main-nav a:hover, .main-nav a.active {\n  color: var(--color-primary);\n}\n\nmain {\n  flex-grow: 1;\n}\n\n.page-title {\n  font-size: 1.5rem;\n  margin-bottom: var(--spacing-lg);\n  color: var(--color-text);\n}\n\n.site-footer {\n  text-align: center;\n  padding: var(--spacing-md);\n  margin-top: var(--spacing-xl);\n  border-top: 1px solid var(--color-border);\n  font-size: 0.9rem;\n  color: var(--color-text-muted);\n}\n\n/* 4. Form Elements */\nform {\n  background-color: var(--color-surface);\n  padding: var(--spacing-lg);\n  border-radius: var(--border-radius);\n  box-shadow: var(--shadow-sm);\n  margin-bottom: var(--spacing-lg);\n}\n\nfieldset {\n  border: none;\n  padding: 0;\n  margin: 0;\n}\n\nlegend {\n  font-size: 1.2rem;\n  font-weight: 600;\n  margin-bottom: var(--spacing-md);\n  color: var(--color-text);\n  padding: 0; /* Resetting some browser defaults */\n  display: block; /* Ensure it takes full width if needed */\n  width: 100%;\n}\n\n.form-group {\n  margin-bottom: var(--spacing-md);\n}\n\n.form-group label {\n  display: block;\n  margin-bottom: var(--spacing-sm);\n  font-weight: 500;\n  color: var(--color-text-muted);\n}\n\n.form-group label small {\n  font-weight: normal;\n  font-size: 0.85em;\n  display: block;\n}\n\ninput[type=\"text\"],\ninput[type=\"search\"],\ninput[type=\"url\"],\ninput[type=\"number\"],\ninput[type=\"email\"],\ntextarea,\nselect {\n  width: 100%;\n  padding: var(--spacing-sm) var(--spacing-md);\n  border: 1px solid var(--color-border);\n  border-radius: var(--border-radius);\n  background-color: var(--color-background); /* Slightly different from surface for depth */\n  color: var(--color-text);\n  transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;\n}\n\ninput[type=\"text\"]:focus,\ninput[type=\"search\"]:focus,\ninput[type=\"url\"]:focus,\ninput[type=\"number\"]:focus,\ninput[type=\"email\"]:focus,\ntextarea:focus,\nselect:focus {\n  outline: none;\n  border-color: var(--color-primary);\n  box-shadow: 0 0 0 0.2rem rgba(var(--color-primary), 0.25);\n}\n\ntextarea {\n  min-height: 100px;\n  resize: vertical;\n}\n\n.input-group {\n  display: flex;\n}\n.input-group input[type=\"search\"] {\n  border-top-right-radius: 0;\n  border-bottom-right-radius: 0;\n  flex-grow: 1;\n}\n.input-group button {\n  border-top-left-radius: 0;\n  border-bottom-left-radius: 0;\n}\n\n\nbutton, .button {\n  display: inline-block;\n  padding: var(--spacing-sm) var(--spacing-lg);\n  font-weight: 500;\n  text-align: center;\n  vertical-align: middle;\n  border: 1px solid transparent;\n  border-radius: var(--border-radius);\n  background-color: var(--color-primary);\n  color: #fff;\n  transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;\n  line-height: 1.5; /* Ensure consistent height with inputs */\n}\n\nbutton:hover, .button:hover {\n  background-color: var(--color-primary-hover);\n}\n\nbutton.secondary, .button.secondary {\n  background-color: var(--color-secondary);\n  color: #fff;\n}\nbutton.secondary:hover, .button.secondary:hover {\n  background-color: darken(var(--color-secondary), 10%);\n}\n\nbutton.danger, .button.danger {\n  background-color: var(--color-danger);\n  color: #fff;\n}\nbutton.danger:hover, .button.danger:hover {\n  background-color: darken(var(--color-danger), 10%);\n}\n\nbutton.icon-button {\n  background: none;\n  border: none;\n  color: var(--color-text-muted);\n  padding: var(--spacing-xs);\n  font-size: 1.2em; /* Adjust as needed */\n  line-height: 1;\n}\nbutton.icon-button:hover {\n  color: var(--color-primary);\n}\n\n\n/* 5. List & Item Styles (for search results, index) */\n.item-list {\n  margin-top: var(--spacing-lg);\n}\n\n.item-list li {\n  background-color: var(--color-surface);\n  padding: var(--spacing-md);\n  margin-bottom: var(--spacing-md);\n  border-radius: var(--border-radius);\n  box-shadow: var(--shadow-sm);\n  border: 1px solid var(--color-border);\n}\n\n.item-list li .item-title {\n  font-size: 1.15rem;\n  font-weight: 600;\n  margin-bottom: var(--spacing-xs);\n}\n.item-list li .item-title a {\n  color: var(--color-primary);\n}\n.item-list li .item-title a:hover {\n  text-decoration: underline;\n}\n\n.item-list li .item-url {\n  font-size: 0.9rem;\n  color: var(--color-text-muted);\n  word-break: break-all;\n  margin-bottom: var(--spacing-sm);\n  display: block; /* Ensure it's on its own line if needed */\n}\n.item-list li .item-url a {\n  color: var(--color-secondary);\n}\n.item-list li .item-url a:hover {\n  text-decoration: underline;\n}\n\n\n.item-list li .item-snippet {\n  font-size: 0.95rem;\n  line-height: 1.6;\n  color: var(--color-text);\n}\n.item-list li .item-snippet mark { /* For highlighted search terms */\n  background-color: var(--color-highlight-bg);\n  color: var(--color-text); /* Ensure text is readable on highlight */\n  padding: 0.1em 0.2em;\n  border-radius: 0.2em;\n}\n\n.item-actions {\n  margin-top: var(--spacing-sm);\n  display: flex;\n  gap: var(--spacing-sm);\n}\n\n\n/* Pagination */\n.pagination {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  gap: var(--spacing-sm);\n  margin-top: var(--spacing-lg);\n  padding: var(--spacing-md);\n}\n.pagination a, .pagination span {\n  padding: var(--spacing-sm) var(--spacing-md);\n  border-radius: var(--border-radius);\n  color: var(--color-primary);\n}\n.pagination a {\n  border: 1px solid var(--color-primary);\n}\n.pagination a:hover {\n  background-color: var(--color-primary);\n  color: #fff;\n}\n.pagination span { /* Current page */\n  background-color: var(--color-primary);\n  color: #fff;\n  font-weight: 600;\n}\n.pagination .disabled {\n    color: var(--color-text-muted);\n    pointer-events: none;\n    border-color: var(--color-border);\n}\n\n\n/* Utilities */\n.text-center {\n  text-align: center;\n}\n.text-muted {\n  color: var(--color-text-muted) !important;\n}\n.mb-0 { margin-bottom: 0 !important; }\n.mt-0 { margin-top: 0 !important; }\n.debug-info {\n  font-size: 0.8rem;\n  color: var(--color-accent);\n  font-family: var(--font-monospace);\n}\n\n/* Specific for edit index delete button */\n.delete-form {\n  display: inline; /* Keep it on the same line */\n}\n.delete-button {\n  background: none;\n  border: none;\n  color: var(--color-danger);\n  padding: 0 var(--spacing-xs);\n  font-size: 1em;\n  cursor: pointer;\n  margin-left: var(--spacing-sm);\n}\n.delete-button:hover {\n  color: darken(var(--color-danger), 15%);\n}\n.strikethrough {\n  text-decoration: line-through;\n  opacity: 0.7;\n}\n\n/* Edit toggle */\n.edit-toggle-section {\n  display: flex;\n  justify-content: flex-end;\n  margin-bottom: var(--spacing-md);\n}\n.edit-toggle-section details {\n  position: relative; /* For absolute positioning of the button */\n}\n.edit-toggle-section summary {\n  display: inline-block;\n  cursor: pointer;\n  padding: var(--spacing-xs) var(--spacing-sm);\n  border-radius: var(--border-radius);\n  background-color: var(--color-surface);\n  border: 1px solid var(--color-border);\n  color: var(--color-text-muted);\n}\n.edit-toggle-section summary:hover {\n  border-color: var(--color-primary);\n  color: var(--color-primary);\n}\n.edit-toggle-section summary::-webkit-details-marker { /* Hide default arrow */\n  display: none;\n}\n.edit-toggle-section summary::marker { /* Hide default arrow FF */\n display: none;\n}\n.edit-toggle-section .details-content {\n  position: absolute;\n  right: 0;\n  top: calc(100% + var(--spacing-xs)); /* Position below the summary */\n  background-color: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--border-radius);\n  padding: var(--spacing-sm);\n  box-shadow: var(--shadow-md);\n  z-index: 10;\n  white-space: nowrap; /* Prevent button text from wrapping */\n}\n\n\n/* Responsive adjustments */\n@media (max-width: 768px) {\n  .site-header {\n    flex-direction: column;\n    align-items: flex-start;\n    gap: var(--spacing-sm);\n  }\n  .main-nav ul {\n    flex-direction: column;\n    gap: var(--spacing-xs);\n  }\n  .input-group {\n    flex-direction: column;\n  }\n  .input-group input[type=\"search\"], .input-group button {\n    border-radius: var(--border-radius); /* Reset individual border radius */\n  }\n  .input-group input[type=\"search\"] {\n    margin-bottom: var(--spacing-sm);\n  }\n}\n\n@media (max-width: 480px) {\n  .container {\n    width: 95%;\n    padding-left: var(--spacing-sm);\n    padding-right: var(--spacing-sm);\n  }\n  .site-header h1 {\n    font-size: 1.5rem;\n  }\n  .page-title {\n    font-size: 1.3rem;\n  }\n  button, .button {\n    padding: var(--spacing-sm) var(--spacing-md); /* Slightly smaller padding */\n  }\n}\n\n/* public/style.css */\n/* ... (keep all existing CSS from the previous version) ... */\n\n/* ADD THE FOLLOWING AT THE END OF THE FILE, OR INTEGRATE INTO RELEVANT SECTIONS */\n\n/* Layout for pages with a sidebar */\n.page-with-sidebar {\n  display: grid;\n  grid-template-columns: 220px 1fr; /* Sidebar width and main content */\n  gap: var(--spacing-lg);\n  flex-grow: 1; /* Ensure it takes available space in the container */\n}\n\n.page-sidebar {\n  background-color: var(--color-surface);\n  padding: var(--spacing-md);\n  border-radius: var(--border-radius);\n  box-shadow: var(--shadow-sm);\n  border-right: 1px solid var(--color-border);\n  height: fit-content; /* So it doesn't stretch unnecessarily if content is short */\n  position: sticky; /* Make sidebar sticky */\n  top: var(--spacing-lg); /* Adjust based on your header or desired spacing */\n}\n\n.page-sidebar h3 {\n  font-size: 1.1rem;\n  font-weight: 600;\n  margin-bottom: var(--spacing-md);\n  padding-bottom: var(--spacing-sm);\n  border-bottom: 1px solid var(--color-border);\n  color: var(--color-text);\n}\n\n.sidebar-nav ul {\n  list-style: none;\n  padding: 0;\n  margin: 0;\n}\n\n.sidebar-nav li a {\n  display: block;\n  padding: var(--spacing-sm) var(--spacing-md);\n  color: var(--color-text-muted);\n  text-decoration: none;\n  border-radius: calc(var(--border-radius) / 2);\n  transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;\n  margin-bottom: var(--spacing-xs);\n}\n\n.sidebar-nav li a:hover {\n  background-color: var(--color-background); /* Subtle hover */\n  color: var(--color-primary);\n}\n\n.sidebar-nav li a.active {\n  background-color: var(--color-primary);\n  color: #fff;\n  font-weight: 500;\n}\n\n.main-content-area {\n  /* This will hold the sections that are shown/hidden */\n}\n\n.main-content-area > section {\n  display: none; /* Hide all sections by default */\n  animation: fadeIn 0.3s ease-in-out;\n}\n\n.main-content-area > section.active-section {\n  display: block; /* Show only the active section */\n}\n\n@keyframes fadeIn {\n  from { opacity: 0; transform: translateY(10px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n\n/* Responsive adjustments for sidebar layout */\n@media (max-width: 992px) { /* Adjust breakpoint as needed */\n  .page-with-sidebar {\n    grid-template-columns: 1fr; /* Stack sidebar and content */\n  }\n  .page-sidebar {\n    position: static; /* Remove stickiness on smaller screens */\n    margin-bottom: var(--spacing-lg);\n    border-right: none;\n    border-bottom: 1px solid var(--color-border);\n  }\n}\n\n/* Styling for form error messages (if not already present or to refine) */\n.form-error-message {\n  color: var(--color-danger);\n  background-color: var(--color-surface); /* Or a light red like #f8d7da */\n  border: 1px solid var(--color-danger);\n  padding: var(--spacing-md);\n  margin-bottom: var(--spacing-md);\n  border-radius: var(--border-radius);\n}\n"
  },
  {
    "path": "public/test-injection.html",
    "content": "<script type=module src=injection.js></script>\n"
  },
  {
    "path": "public/top.html",
    "content": "<script>\n  \n</script>\n"
  },
  {
    "path": "scripts/build_only.sh",
    "content": "#!/usr/bin/env bash\n\n#set -x\nsource $HOME/.nvm/nvm.sh\n\nrm -rf build\nmkdir -p build/esm/\nmkdir -p build/cjs/\nmkdir -p build/global/\nmkdir -p build/bin/\nnvm use v22\nif [[ ! -d \"node_modules\" ]]; then\n  npm i\nfi\nif [[ -n \"$NO_MINIFY\" ]]; then\n  ./node_modules/.bin/esbuild src/app.js --bundle --outfile=build/esm/downloadnet.mjs --format=esm --platform=node --analyze\n  ./node_modules/.bin/esbuild src/app.js --bundle --outfile=build/cjs/out.cjs --platform=node --analyze\nelse\n  ./node_modules/.bin/esbuild src/app.js --bundle --outfile=build/esm/downloadnet.mjs --format=esm --platform=node --minify --analyze\n  ./node_modules/.bin/esbuild src/app.js --bundle --outfile=build/cjs/out.cjs --platform=node --minify --analyze\nfi\ncp -r public build/\necho \"const bigR = require('module').createRequire(__dirname); require = bigR; process.traceProcessWarnings = true; \" > build/cjs/dn.cjs\n# polyfill for process.disableWarning idea as node arg --disableWarning=ExperimentalWarning is likely not accessible in this setup\n#echo \"const __orig_emit = process.emit; process.emit = (event, error) => event === 'warning' && error.name === 'ExperimentalWarning' ? false : originalEmit.call(process, event, error);\" >> build/cjs/dn.cjs\n# although we can use the sea config key disableExperimentalSEAWarning to achieve same \ncat build/cjs/out.cjs >> build/cjs/dn.cjs\necho \"#!/usr/bin/env node\" > build/global/downloadnet.cjs\ncat build/cjs/dn.cjs >> build/global/downloadnet.cjs\nchmod +x build/global/downloadnet.cjs\nif [[ \"$OSTYPE\" == darwin* ]]; then\n  echo \"Using macOS builder...\" >&2\n  ./stampers/macos-new.sh dn-macos build/cjs/dn.cjs build/bin/\n  #./stampers/macos.sh dn-macos build/cjs/dn.cjs build/bin/\nelif [[ \"$(node.exe -p process.platform)\" == win* ]]; then\n  echo \"Using windows builder...\" >&2\n  ./stampers/win.bat dn-win.exe ./build/cjs/dn.cjs ./build/bin/\nelse\n  echo \"Using linux builder...\" >&2\n  ./stampers/nix.sh dn-nix build/cjs/dn.cjs build/bin/\nfi\necho \"Done\"\n\nread -p \"Any key to exit\"\n\n"
  },
  {
    "path": "scripts/clean.sh",
    "content": "#!/usr/bin/env bash\n\nrm package-lock.json; rm -rf node_modules; rm -rf build/*\n"
  },
  {
    "path": "scripts/downloadnet-entitlements.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>com.apple.security.network.server</key>\n    <true/>\n  <key>com.apple.security.cs.allow-jit</key>\n    <true/>\n  <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n    <true/>\n  <key>com.apple.security.cs.disable-library-validation</key>\n    <true/>\n  <key>com.apple.security.cs.disable-executable-page-protection</key>\n    <true/>\n</dict>\n</plist>\n\n"
  },
  {
    "path": "scripts/go_build.sh",
    "content": "#!/usr/bin/env bash\n\ncp ./.package.build.json ./package.json\ncp ./src/.common.build.js ./src/common.js\n\n"
  },
  {
    "path": "scripts/go_dev.sh",
    "content": "#!/usr/bin/env bash\n\ngut \"Just built\"\ncp ./.package.dev.json ./package.json\ncp ./src/.common.dev.js ./src/common.js\n\n"
  },
  {
    "path": "scripts/postinstall.sh",
    "content": "#!/usr/bin/env bash\n\nwhich brew || /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\nwhich mkcert || brew install mkcert\nmkdir -p $HOME/local-sslcerts\ncd $HOME/local-sslcerts\n\nmkcert -key-file privkey.pem -cert-file fullchain.pem localhost\nmkcert -install\n\n"
  },
  {
    "path": "scripts/publish.sh",
    "content": "#!/usr/bin/env bash\n\n./scripts/go_build.sh\ngpush minor \"$@\"\n./scripts/go_dev.sh\n\n"
  },
  {
    "path": "scripts/release.sh",
    "content": "#!/bin/sh\n\n#./scripts/compile.sh\ndescription=$1\nlatest_tag=$(git describe --abbrev=0)\ngrel release -u o0101 -r dn --tag $latest_tag --name \"New release\" --description '\"'\"$description\"'\"'\ngrel upload -u o0101 -r dn --tag $latest_tag --name \"downloadnet-win.exe\" --file bin/downloadnet-win.exe\ngrel upload -u o0101 -r dn --tag $latest_tag --name \"downloadnet-linux\" --file bin/downloadnet-linux\ngrel upload -u o0101 -r dn --tag $latest_tag --name \"downloadnet-macos\" --file bin/downloadnet-macos\n\n\n\n"
  },
  {
    "path": "scripts/sign_windows_release.ps1",
    "content": "param (\n    [Parameter(Mandatory=$true)]\n    [string]$ExePath,\n\n    [Parameter(Mandatory=$true)]\n    [string]$KeyVaultName,\n\n    [string]$SubscriptionId,\n    [string]$ResourceGroup,\n    [string]$CertificateName,\n    [string]$AppId,\n    [string]$ClientSecret,\n    [string]$TenantId,\n\n    # --- Version Info Metadata ---\n    [string]$CompanyName = \"DOSAYGO\",\n    [string]$ProductName = \"DownloadNet\",\n    [string]$FileDescription = \"Offline full-text search archive of what you browse\",\n    [string]$FileVersion = \"4.5.1.0\",\n    [string]$ProductVersion = \"4.5.1.0\",\n\n    # --- Signature Metadata ---\n    [string]$SignatureDescription = \"DownloadNet - offline full-text search archive of the web for you.\",\n    [string]$SignatureUrl = \"https://github.com/DO-SAY-GO/dn\"\n)\n\n# --- Function to check/install resedit-cli via npm ---\nfunction Ensure-ReseditInstalled {\n    $isInstalled = Get-Command \"resedit\" -ErrorAction SilentlyContinue\n\n    if (-not $isInstalled) {\n        Write-Host \"resedit-cli not found. Attempting to install with npm...\" -ForegroundColor Yellow\n        npm i -g resedit-cli\n        if ($LASTEXITCODE -ne 0) {\n            Write-Error \"Failed to install resedit-cli using npm. Ensure npm is installed and accessible.\"\n            exit 1\n        }\n        # Refresh PATH to include newly installed resedit-cli\n        $env:Path = [System.Environment]::GetEnvironmentVariable(\"Path\", \"User\") + \";\" + [System.Environment]::GetEnvironmentVariable(\"Path\", \"Machine\")\n    } else {\n        Write-Host \"resedit-cli is already installed.\" -ForegroundColor Green\n    }\n}\n\n# --- Call resedit-cli to update version metadata ---\nfunction Set-VersionMetadata {\n    Ensure-ReseditInstalled\n\n    Write-Host \"Setting executable metadata using resedit-cli...\" -ForegroundColor Yellow\n    $tempOutput = \"$ExePath.tmp.exe\"\n    $reseditArgs = @(\n        \"--in\", \"`\"$ExePath`\"\",\n        \"--out\", \"`\"$tempOutput`\"\",\n        \"--company-name\", \"`\"$CompanyName`\"\",\n        \"--product-name\", \"`\"$ProductName`\"\",\n        \"--file-description\", \"`\"$FileDescription`\"\",\n        \"--file-version\", \"`\"$FileVersion`\"\",\n        \"--product-version\", \"`\"$ProductVersion`\"\"\n    )\n\n    $reseditCommand = \"resedit $reseditArgs\"\n    Write-Verbose \"Executing: $reseditCommand\"\n    Invoke-Expression $reseditCommand\n\n    if ($LASTEXITCODE -ne 0) {\n        Write-Error \"resedit-cli failed to apply version metadata.\"\n        if (Test-Path $tempOutput) { Remove-Item $tempOutput -Force }\n        exit 1\n    }\n\n    # Replace original file with updated one\n    Move-Item -Path $tempOutput -Destination $ExePath -Force\n    Write-Host \"Version metadata applied successfully.\" -ForegroundColor Green\n}\n\n# --- RUN METADATA SETTING STEP FIRST ---\nSet-VersionMetadata\n\n# --- Configuration (Defaults from original script) ---\n$DefaultSPNName = \"CodeSigningSP\" # Original SPN name\n$TimestampServer = \"http://timestamp.digicert.com\"\n$AzureSignToolExe = \"AzureSignTool.exe\" # Assumes in PATH\n$SignToolExe = \"signtool.exe\"           # Assumes in PATH\n\n# --- Original Script's Flow (unchanged) ---\n\nfunction Show-Usage {\n    Write-Host \"Usage: .\\sign_windows_downloadnet_configurable_metadata.ps1 -ExePath <path> -KeyVaultName <kv-name> [-SubscriptionId <sub-id>] [-ResourceGroup <rg>] [-CertificateName <cert-name>] [-AppId <id> -ClientSecret <secret> -TenantId <tenant>] [-SignatureDescription <desc>] [-SignatureUrl <url>]\"\n    exit 1\n}\n\nif (-not $ExePath -or -not $KeyVaultName) { Show-Usage }\nif ($AppId -and (-not $ClientSecret -or -not $TenantId)) { Write-Error \"Error: If -AppId is provided, -ClientSecret and -TenantId must also be provided.\"; Show-Usage }\nif (-not (Test-Path $ExePath -PathType Leaf)) { Write-Error \"Error: Executable not found at path: $ExePath\"; exit 1 }\n\nif (-not $SubscriptionId) {\n    Write-Host \"Fetching the active Azure subscription...\"\n    $subscriptionOutput = az account show | ConvertFrom-Json -ErrorAction SilentlyContinue\n    if ($LASTEXITCODE -ne 0 -or !$subscriptionOutput.id) { Write-Error \"Error: Failed to retrieve active subscription. Ensure 'az' CLI is installed and you are logged in with 'az login'.\"; exit 1 }\n    $SubscriptionId = $subscriptionOutput.id\n    Write-Host \"Using active subscription ID: $SubscriptionId\"\n}\n\nWrite-Host \"Setting active subscription to: $SubscriptionId\"\naz account set --subscription $SubscriptionId\nif ($LASTEXITCODE -ne 0) { Write-Error \"Error: Failed to set active subscription.\"; exit 1 }\n\nWrite-Host \"Fetching Key Vault details for: $KeyVaultName\"\n$keyVaultOutput = az keyvault show --name $KeyVaultName --subscription $SubscriptionId | ConvertFrom-Json -ErrorAction SilentlyContinue\nif ($LASTEXITCODE -ne 0 -or !$keyVaultOutput.properties.vaultUri) { Write-Error \"Error: Failed to retrieve Key Vault details.\"; exit 1 }\n$KeyVaultUrl = $keyVaultOutput.properties.vaultUri\nWrite-Host \"Key Vault URL: $KeyVaultUrl\"\n\nif (-not $ResourceGroup) {\n    $ResourceGroup = $keyVaultOutput.resourceGroup\n    if (-not $ResourceGroup) { Write-Error \"Error: Could not retrieve resource group from Key Vault details.\"; exit 1 }\n    Write-Host \"Using resource group from Key Vault: $ResourceGroup\"\n}\n\nif (-not $CertificateName) {\n    Write-Host \"CertificateName not provided. Fetching available certificates in Key Vault: $KeyVaultName\"\n    $certListOutput = az keyvault certificate list --vault-name $KeyVaultName | ConvertFrom-Json -ErrorAction SilentlyContinue\n    if ($LASTEXITCODE -ne 0 -or !$certListOutput) { Write-Error \"Error: Failed to list certificates in Key Vault, or no certificates found.\"; exit 1 }\n    $certificates = @($certListOutput)\n    if ($certificates.Count -eq 0) { Write-Error \"Error: No certificates found in Key Vault: $KeyVaultName\"; exit 1 }\n    Write-Host \"Available certificates:\"\n    $certificates | ForEach-Object { Write-Host \"  - $($_.name)\" }\n    $CertificateName = $certificates[0].name\n    Write-Host \"Using first available certificate: $CertificateName\" -ForegroundColor Green\n}\n\nif (-not $AppId) {\n    Write-Host \"Service Principal AppId not provided. Creating a new service principal named '$DefaultSPNName'...\"\n    $scope = \"/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.KeyVault/vaults/$KeyVaultName\"\n    # Using \"Contributor\" role as in the original script.\n    # For production, consider least privilege (e.g., custom role with only cert get & key sign).\n    $spnOutput = az ad sp create-for-rbac --name $DefaultSPNName --role Contributor --scopes $scope | ConvertFrom-Json -ErrorAction SilentlyContinue\n    if ($LASTEXITCODE -ne 0 -or !$spnOutput.appId) { Write-Error \"Error: Failed to create service principal.\"; exit 1 }\n    $AppId = $spnOutput.appId\n    $ClientSecret = $spnOutput.password\n    $TenantId = $spnOutput.tenant\n    Write-Host \"Service principal '$DefaultSPNName' created successfully.\" -ForegroundColor Green\n    Write-Host \"AppId   : $AppId\"\n    Write-Host \"Secret  : $ClientSecret (Note: This secret is shown only once. Store it securely.)\"\n    Write-Host \"TenantId: $TenantId\"\n\n    # Grant permissions using set-policy as in the original script\n    Write-Host \"Setting Key Vault access policy for SPN '$AppId'...\"\n    az keyvault set-policy --name $KeyVaultName --spn $AppId --key-permissions sign --certificate-permissions get\n    if ($LASTEXITCODE -ne 0) { Write-Error \"Error: Failed to set Key Vault policy.\"; exit 1 }\n    Write-Host \"Key Vault access policy set successfully.\" -ForegroundColor Green\n}\n\n# --- Construct AzureSignTool command with metadata flags ---\n$signToolBaseArgs = @(\n    \"sign\",\n    \"-kvu\", \"`\"$KeyVaultUrl`\"\",\n    \"-kvi\", \"`\"$AppId`\"\",\n    \"-kvs\", \"`\"$ClientSecret`\"\", # ClientSecret might contain special characters\n    \"-kvt\", \"`\"$TenantId`\"\",\n    \"-kvc\", \"`\"$CertificateName`\"\",\n    \"-tr\", \"`\"$TimestampServer`\"\"\n)\n# Add description if provided\nif ($SignatureDescription) {\n    $signToolBaseArgs += \"-d\", \"`\"$SignatureDescription`\"\"\n}\n# Add description URL if provided\nif ($SignatureUrl) {\n    $signToolBaseArgs += \"-du\", \"`\"$SignatureUrl`\"\"\n}\n# Add verbose flag and executable path\n$signToolBaseArgs += \"-v\", \"`\"$ExePath`\"\"\n\n$signCommand = \"$AzureSignToolExe $($signToolBaseArgs -join ' ')\"\n\nWrite-Host \"Signing the executable: $ExePath (Cert: $CertificateName, KV: $KeyVaultName)\" -ForegroundColor Yellow\nWrite-Verbose \"Executing: $signCommand\"\n$signOutput = Invoke-Expression $signCommand\n\nif ($LASTEXITCODE -ne 0) {\n    Write-Error \"Error: Failed to sign the executable with AzureSignTool. Exit code: $LASTEXITCODE\"\n    Write-Error \"AzureSignTool Output: $signOutput\"\n    exit 1\n}\nWrite-Host \"Executable signed successfully by AzureSignTool.\" -ForegroundColor Green\n$signOutput | Write-Host\n\nWrite-Host \"Verifying the signature using $SignToolExe...\" -ForegroundColor Yellow\n$verifyCommand = \"$SignToolExe verify /pa `\"$ExePath`\"\"\nWrite-Verbose \"Executing: $verifyCommand\"\n$verifyOutput = Invoke-Expression $verifyCommand\n\nif ($LASTEXITCODE -ne 0) {\n    Write-Error \"Error: Signature verification failed with $SignToolExe. Exit code: $LASTEXITCODE\"\n    Write-Error \"$SignToolExe Output: $verifyOutput\"\n    exit 1\n}\nWrite-Host \"Signature verified successfully by $SignToolExe.\" -ForegroundColor Green\n$verifyOutput | Write-Host\n\nWrite-Host \"Signing process completed.\" -ForegroundColor Green\n"
  },
  {
    "path": "sign-win.ps1",
    "content": ".\\scripts\\sign_windows_release.ps1 -ExePath .\\build\\bin\\dn-win.exe -KeyVaultName codeSigningForever\n\n"
  },
  {
    "path": "src/app.js",
    "content": "// app.js\nimport os from 'os';\nimport path from 'path';\nimport fs from 'fs/promises';\nimport { exec } from 'child_process';\nimport { promisify } from 'util';\nimport inquirer from 'inquirer';\nimport chalk from 'chalk';\n// import ChromeLauncher from './launcher.js'; // OLD\nimport BrowserLauncher from './launcher.js'; // NEW - Renamed for clarity\nimport psList from '@667/ps-list';\nimport { DEBUG, sleep, NO_SANDBOX, GO_SECURE } from './common.js';\nimport { Archivist } from './archivist.js';\nimport LibraryServer from './libraryServer.js';\nimport args from './args.js';\n\nconst { server_port, mode, chrome_port } = args;\nconst execAsync = promisify(exec);\n\n// Browser definitions with platform-specific executable, package names, and paths\nconst BROWSERS = [\n  {\n    name: 'Chrome',\n    // For psList matching, use a pattern that matches the process name or command line.\n    // For launching, we'll find the specific executable.\n    psPattern: /chrome$/i, // Matches 'chrome' or 'google-chrome' at the end of a path/name\n    executables: { // Platform-specific executable names (for `where` or `command -v`)\n        win32: 'chrome.exe',\n        darwin: 'Google Chrome', // Application name for `open -a` or finding in /Applications\n        linux: 'google-chrome',\n        freebsd: 'chrome'\n    },\n    // For direct launch if found via these paths\n    defaultPaths: [\n      '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n      'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',\n      'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',\n      '/usr/bin/google-chrome',\n      '/usr/local/bin/google-chrome'\n    ],\n    // For RDP check, what does data.Browser typically start with?\n    rdpBrowserName: /Chrome/i,\n    // For user display and installation guidance\n    packageName: { linux: 'google-chrome-stable', darwin: 'https://www.google.com/chrome/', win32: 'https://www.google.com/chrome/', freebsd: 'chrome' },\n  },\n  {\n    name: 'Chromium',\n    psPattern: /chromium(-browser)?$/i,\n    executables: {\n        win32: 'chromium.exe', // Often chrome.exe if it's a Chromium build\n        darwin: 'Chromium',\n        linux: 'chromium-browser', // or just 'chromium'\n        freebsd: 'chromium'\n    },\n    defaultPaths: [\n      '/Applications/Chromium.app/Contents/MacOS/Chromium',\n      'C:\\\\Program Files\\\\Chromium\\\\Application\\\\chrome.exe', // Some builds use chrome.exe\n      'C:\\\\Program Files\\\\Chromium\\\\Application\\\\chromium.exe',\n      '/usr/bin/chromium-browser',\n      '/usr/bin/chromium',\n      '/usr/local/bin/chromium-browser',\n      '/usr/local/bin/chromium'\n    ],\n    rdpBrowserName: /Chromium/i,\n    packageName: { linux: 'chromium-browser', darwin: 'https://www.chromium.org/getting-involved/download-chromium/', win32: 'https://www.chromium.org/getting-involved/download-chromium/', freebsd: 'chromium' },\n  },\n  {\n    name: 'Vivaldi',\n    psPattern: /vivaldi$/i,\n    executables: { win32: 'vivaldi.exe', darwin: 'Vivaldi', linux: 'vivaldi', freebsd: 'vivaldi' },\n    defaultPaths: [\n      '/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',\n      'C:\\\\Program Files\\\\Vivaldi\\\\Application\\\\vivaldi.exe',\n      '/usr/bin/vivaldi',\n      '/usr/local/bin/vivaldi'\n    ],\n    rdpBrowserName: /Vivaldi/i,\n    packageName: { linux: 'vivaldi-stable', darwin: 'https://vivaldi.com/download/', win32: 'https://vivaldi.com/download/', freebsd: 'vivaldi' },\n  },\n  {\n    name: 'Brave',\n    psPattern: /brave(-browser)?$/i,\n    executables: { win32: 'brave.exe', darwin: 'Brave Browser', linux: 'brave-browser', freebsd: 'brave' },\n    defaultPaths: [\n      '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',\n      'C:\\\\Program Files\\\\BraveSoftware\\\\Brave-Browser\\\\Application\\\\brave.exe',\n      '/usr/bin/brave-browser',\n      '/usr/local/bin/brave-browser'\n    ],\n    rdpBrowserName: /Brave/i,\n    packageName: { linux: 'brave-browser', darwin: 'https://brave.com/download/', win32: 'https://brave.com/download/', freebsd: 'brave' },\n  },\n  {\n    name: 'Edge',\n    psPattern: /(msedge|microsoft-edge)$/i,\n    executables: { win32: 'msedge.exe', darwin: 'Microsoft Edge', linux: 'microsoft-edge', freebsd: 'edge' },\n    defaultPaths: [\n      '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',\n      'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',\n      'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',\n      '/usr/bin/microsoft-edge',\n      '/usr/local/bin/microsoft-edge'\n    ],\n    rdpBrowserName: /Edg/i,\n    packageName: { linux: 'microsoft-edge-stable', darwin: 'https://www.microsoft.com/edge', win32: 'https://www.microsoft.com/edge', freebsd: 'edge' },\n  }\n];\n\n\n// Base Chrome launch flags\nconst BASE_CHROME_FLAGS = [\n  `--remote-debugging-port=${chrome_port}`,\n  `--disk-cache-dir=${args.temp_browser_cache()}`,\n  `--aggressive-cache-discard`,\n  // '--no-first-run', // Often useful\n  // '--no-default-browser-check', // Often useful\n  // '--disable-features=TranslateUI', // Example: disable a feature\n  // '--disable-default-apps',\n  // '--disable-component-update',\n  // '--disable-background-networking',\n  // '--disable-sync',\n  // '--metrics-recording-only',\n  // '--disable-breakpad', // Disables crash reporting\n];\nif (NO_SANDBOX) {\n  BASE_CHROME_FLAGS.push('--no-sandbox');\n  // On Linux, --no-sandbox often requires --disable-setuid-sandbox as well,\n  // or running as root (which is not recommended for browsers).\n  // if (process.platform === 'linux') BASE_CHROME_FLAGS.push('--disable-setuid-sandbox');\n}\nif (process.env.DK_HEADLESS) {\n  BASE_CHROME_FLAGS.push('--headless=new'); // Modern headless\n  // BASE_CHROME_FLAGS.push('--disable-gpu'); // Often needed with headless\n  // BASE_CHROME_FLAGS.push('--window-size=1920,1080'); // Example size\n}\n\n// Platform-specific kill commands\n// Uses the executable name for killing, which should be more reliable\nconst KILL_ON = browserDefinition => {\n    const execName = browserDefinition.executables[process.platform];\n    if (!execName) return {}; // Should not happen if browserDefinition is valid\n    return {\n        win32: `taskkill /IM ${execName} /F`,\n        darwin: `pkill -if \"${execName}\"`, // pkill -if is case-insensitive and matches full path\n        freebsd: `pkill -15 -f \"${execName}\"`, // -f to match against full command line\n        linux: `pkill -15 -f \"${execName}\"`   // -f to match against full command line\n    };\n};\n\n\nlet quitting = false;\n\n// Start the application\nstart().catch(async err => {\n  console.error(chalk.red('Critical startup error:'), err);\n  await cleanup('Startup error', err, { exit: true });\n});\n\nasync function promptUser(question, options) {\n  const choices = options.map((opt, i) => ({\n    name: `${i + 1}. ${opt.text}`,\n    value: opt.value\n  }));\n  const defaultChoice = options.find(opt => opt.default)?.value || (choices.length > 0 ? choices[0].value : null);\n\n  const { choice } = await inquirer.prompt([\n    {\n      type: 'list',\n      name: 'choice',\n      message: chalk.blue.bold(question),\n      choices,\n      default: defaultChoice\n    }\n  ]);\n  return choice;\n}\n\n// --- MODIFIED: findExecutablePath ---\n// Finds the executable path for a given browser definition\nasync function findExecutablePath(browserDef) {\n    const platform = process.platform;\n    const execName = browserDef.executables[platform];\n\n    // 1. Check predefined defaultPaths\n    for (const p of browserDef.defaultPaths) {\n        // Ensure path is relevant for current platform (e.g. C:\\ for win32)\n        if ((platform === 'win32' && p.includes(':')) || (platform !== 'win32' && p.startsWith('/'))) {\n            try {\n                await fs.access(p, fs.constants.X_OK); // Check if exists and is executable\n                DEBUG.verbose && console.log(`Found ${browserDef.name} at default path: ${p}`);\n                return p;\n            } catch { /* Path not accessible or doesn't exist */ }\n        }\n    }\n\n    // 2. Check system PATH (where/command -v)\n    if (execName) {\n        try {\n            const cmd = platform === 'win32' ? `where ${execName}` : `command -v ${execName}`;\n            const { stdout } = await execAsync(cmd, { shell: platform === 'win32' ? 'cmd.exe' : '/bin/bash' });\n            const foundPath = stdout.trim().split('\\n')[0]; // Take the first result\n            if (foundPath) {\n                 await fs.access(foundPath, fs.constants.X_OK); // Verify it's executable\n                 DEBUG.verbose && console.log(`Found ${browserDef.name} in PATH: ${foundPath}`);\n                 return foundPath;\n            }\n        } catch { /* Not in PATH or not executable */ }\n    }\n    \n    DEBUG.verbose && console.log(`Executable path for ${browserDef.name} not found.`);\n    return null;\n}\n\n\nasync function detectInstalledBrowsers() {\n  const installed = [];\n  for (const browserDef of BROWSERS) {\n    const executablePath = await findExecutablePath(browserDef);\n    if (executablePath) {\n      // Store the found executable path in the browser definition for later use\n      installed.push({ ...browserDef, foundPath: executablePath });\n    }\n  }\n  return installed;\n}\n\nasync function checkIsConnectable(browserDef) { // Takes browserDef\n  const hosts = ['localhost', '127.0.0.1', '[::1]'];\n  for (const host of hosts) {\n    try {\n      const url = `http://${host}:${chrome_port}/json/version`;\n      DEBUG.verbose && console.log(`RDP Check: Testing ${url} for ${browserDef.name}`);\n      // node-fetch might require specific agent for http, or adjust timeout\n      const response = await fetch(url, { timeout: 700 }); // 700ms timeout\n      if (response.ok) {\n        const data = await response.json();\n        DEBUG.verbose && console.log(`RDP Response from ${host}:${chrome_port}:`, data.Browser);\n        if (data.Browser && browserDef.rdpBrowserName.test(data.Browser)) {\n          DEBUG.verbose && console.log(chalk.green(`RDP Connectable: ${browserDef.name} on ${host}:${chrome_port}`));\n          return true;\n        }\n      }\n    } catch (e) {\n      DEBUG.verboseSlow && console.warn(chalk.yellow(`RDP check failed for ${browserDef.name} on ${host}:${chrome_port}: ${e.message.split('\\n')[0]}`));\n    }\n  }\n  return false;\n}\n\nasync function detectBrowsers() {\n  const processes = await psList();\n  (DEBUG.verbose || DEBUG.showList) && console.log(\"Running processes:\", processes.map(p=>p.name).filter(Boolean).join(', '));\n\n  const installedBrowserDefs = await detectInstalledBrowsers(); // These now include 'foundPath'\n  \n  const browserStatus = await Promise.all(\n    // Map over all BROWSERS definitions, but enrich with foundPath if installed\n    BROWSERS.map(async baseBrowserDef => {\n      const installedDef = installedBrowserDefs.find(ib => ib.name === baseBrowserDef.name);\n      const browserDef = installedDef || baseBrowserDef; // Use enriched def if available\n\n      const proc = processes.find(({ name, cmd, path: procPath }) => {\n        // Try to match against the psPattern or the executable name\n        const execNameForPs = browserDef.executables[process.platform];\n        return (name && browserDef.psPattern.test(name)) || \n               (cmd && browserDef.psPattern.test(cmd)) ||\n               (procPath && execNameForPs && procPath.toLowerCase().includes(execNameForPs.toLowerCase()));\n      });\n      const isRunning = !!proc;\n      const isConnectable = isRunning && browserDef.foundPath && await checkIsConnectable(browserDef); // Only check if installed\n      \n      return { \n        ...browserDef, // Includes name, psPattern, executables, defaultPaths, rdpBrowserName, packageName\n        isInstalled: !!browserDef.foundPath, // True if foundPath exists\n        isRunning, \n        isConnectable, \n        proc \n        // foundPath is already part of browserDef if installed\n      };\n    })\n  );\n\n  const installed = browserStatus.filter(b => b.isInstalled);\n  const running = browserStatus.filter(b => b.isRunning && b.isInstalled); // Only consider installed browsers as \"running\" for our purposes\n  \n  return { installed, running, all: browserStatus };\n}\n\n\nasync function killBrowser(browserName) {\n  const browserDefinition = BROWSERS.find(b => b.name === browserName);\n  if (!browserDefinition) {\n      console.warn(chalk.yellow(`No definition found for browser ${browserName} to kill.`));\n      return;\n  }\n  const execNameForKill = browserDefinition.executables[process.platform];\n  if (!execNameForKill) {\n      console.warn(chalk.yellow(`No executable defined for ${browserName} on ${process.platform} to kill.`));\n      return;\n  }\n\n  const killCommands = KILL_ON(browserDefinition); // Pass the full definition\n  if (!(process.platform in killCommands)) {\n    console.warn(chalk.yellow(`Platform ${process.platform} not supported for killing ${browserName}. Please close it manually.`));\n    return;\n  }\n\n  try {\n    console.log(chalk.cyan(`Attempting to shut down ${browserName} (processes matching ${execNameForKill})...`));\n    const killCommand = killCommands[process.platform];\n    DEBUG.verbose && console.log(`Executing kill command: ${killCommand}`);\n    const { stdout, stderr } = await execAsync(killCommand, { shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash' });\n    \n    if (stderr && !stderr.toLowerCase().includes('no tasks running') && !stderr.toLowerCase().includes('not found') && !stderr.toLowerCase().includes('no process found')) {\n      DEBUG.verboseSlow && console.warn(chalk.yellow(`Error output during kill for ${browserName}: ${stderr.trim()}`));\n      console.log(chalk.cyan(`${browserName} might not have been running or an issue occurred during shutdown.`));\n    } else if (stdout.toLowerCase().includes('terminated') || stdout.toLowerCase().includes('success') || !stderr || stderr.toLowerCase().includes('no tasks running') || stderr.toLowerCase().includes('not found') || stderr.toLowerCase().includes('no process found')) {\n      console.log(chalk.green(`${browserName} processes shut down or were not running.`));\n    } else {\n      console.log(chalk.green(`${browserName} shutdown command issued.`));\n    }\n    await sleep(1000);\n  } catch (e) {\n    if (e.message.toLowerCase().includes('process not found') || e.message.toLowerCase().includes('no matching processes') || e.message.toLowerCase().includes('no tasks running')) {\n        console.log(chalk.cyan(`${browserName} was not found or already closed.`));\n    } else {\n        console.warn(chalk.yellow(`Error executing kill command for ${browserName}: ${e.message}`));\n    }\n  }\n}\n\nasync function cleanTempCache() {\n  const tempDir = args.temp_browser_cache();\n  try {\n    await fs.access(tempDir); // Check if exists first\n    console.log(chalk.cyan(`Removing temporary browser cache (${tempDir})...`));\n    await fs.rm(tempDir, { recursive: true, force: true });\n    console.log(chalk.green(`Temporary cache deleted.`));\n  } catch (e) {\n    if (e.code === 'ENOENT') {\n        DEBUG.verbose && console.log(chalk.cyan(`Temporary cache directory (${tempDir}) not found, nothing to delete.`));\n    } else {\n        console.warn(chalk.yellow(`Error deleting temporary cache: ${e.message}`));\n    }\n  }\n}\n\nasync function start() {\n  console.log(chalk.cyan(`DownloadNet starting...`));\n\n  const signals = ['error', 'unhandledRejection', 'uncaughtException', 'SIGHUP', 'beforeExit'];\n  signals.forEach(signal => process.on(signal, async (err) => await cleanup(err?.message || signal, err)));\n  const exitSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGBREAK', 'SIGABRT'];\n  exitSignals.forEach(signal => process.on(signal, async (code) => await cleanup(code, 'signal', { exit: true })));\n\n  console.log(chalk.cyan(`Checking browsers...`));\n  const { installed, running, all: browserStatus } = await detectBrowsers();\n  const connectable = browserStatus.filter(b => b.isConnectable && b.isInstalled);\n\n  console.log(chalk.blue.bold(`\\nBrowser Status:`));\n  if ( DEBUG.verbose ) {\n    console.log(chalk.cyan(`  Installed: ${installed.map(b => `${b.name} (at ${b.foundPath || 'path not confirmed'})`).join(', ') || 'None'}`));\n  } else {\n    console.log(chalk.cyan(`  Installed: ${installed.map(b => `${b.name}`).join(', ') || 'None'}`));\n  }\n  console.log(chalk.cyan(`  Running:   ${running.map(b => b.name).join(', ') || 'None'}`));\n  console.log(chalk.cyan(`  Connectable: ${connectable.map(b => b.name).join(', ') || 'None'}`));\n\n  let action = null;\n  const menuOptions = [];\n\n  connectable.forEach(b => menuOptions.push({\n    text: `Use running ${b.name} (already open and connectable)`,\n    value: { action: 'connect', browser: b },\n    default: true\n  }));\n\n  running.forEach(b => {\n    // Only offer relaunch if not already connectable, or if user might want a fresh start\n    if (!connectable.some(cb => cb.name === b.name)) {\n        menuOptions.push({\n            text: `Relaunch ${b.name} (to enable archiving features)`,\n            value: { action: 'relaunch', browser: b }\n        });\n    }\n  });\n  \n  // Offer to launch installed but not running browsers\n  installed.forEach(b => {\n    if (!running.some(rb => rb.name === b.name)) {\n      menuOptions.push({\n        text: `Launch ${b.name} (new instance)`,\n        value: { action: 'launch', browser: b }\n      });\n    }\n  });\n\n  // Offer to install browsers not detected as installed\n  BROWSERS.forEach(bDef => {\n    if (!installed.some(ib => ib.name === bDef.name)) {\n      menuOptions.push({\n        text: `Install and launch ${bDef.name} (requires installation)`,\n        value: { action: 'install', browser: bDef } // Pass the base definition\n      });\n    }\n  });\n  \n  if (running.length > 0) {\n    menuOptions.push({\n      text: 'Shut down all detected browser processes and exit',\n      value: { action: 'shutdown_all_and_exit' }\n    });\n  }\n  menuOptions.push({ text: 'Exit', value: { action: 'exit_only' } });\n\n  const uniqueMenuOptions = [];\n  const seenValues = new Set();\n  for (const opt of menuOptions) {\n      let key = opt.value.action;\n      if (opt.value.browser) key += `_${opt.value.browser.name}`;\n      if (!seenValues.has(key)) {\n          uniqueMenuOptions.push(opt);\n          seenValues.add(key);\n      } else if (opt.default) {\n          const existingIndex = uniqueMenuOptions.findIndex(uo => (uo.value.action + (uo.value.browser ? `_${uo.value.browser.name}`: '')) === key);\n          if (existingIndex !== -1) uniqueMenuOptions[existingIndex] = opt;\n      }\n  }\n\n  if (uniqueMenuOptions.some(opt => opt.value.action !== 'exit_only')) {\n    action = await promptUser('Select an action:', uniqueMenuOptions);\n  } else {\n    console.log(chalk.red('No actionable browser options. Please install a compatible browser.'));\n    await cleanup('No browsers available or actionable', null, { exit: true });\n    return;\n  }\n\n  if (!action || action.action === 'exit_only') {\n    console.log(chalk.cyan('Exiting as requested.'));\n    await cleanup('User chose to exit', null, { exit: true });\n    return;\n  }\n\n  if (action.action === 'shutdown_all_and_exit') {\n    console.log(chalk.cyan('Attempting to shut down all detected running browser processes...'));\n    const runningToKill = browserStatus.filter(b => b.isRunning && b.isInstalled); // Get full defs\n    if (runningToKill.length > 0) {\n      for (const browserToKill of runningToKill) {\n        await killBrowser(browserToKill.name); // killBrowser uses name to find definition\n      }\n      console.log(chalk.green('Shutdown commands issued for all detected running browser processes.'));\n    } else {\n      console.log(chalk.cyan('No running browser processes (that we manage) were detected to shut down.'));\n    }\n    await cleanup('User chose to shut down all browsers and exit', null, { exit: true });\n    return;\n  }\n\n  let browserToUse = action.browser; // This is a browser definition object\n\n  if (action.action === 'connect') {\n    console.log(chalk.cyan(`Connecting to running ${browserToUse.name}...`));\n  } else if (action.action === 'relaunch') {\n    console.log(chalk.cyan(`Relaunching ${browserToUse.name}...`));\n    await killBrowser(browserToUse.name);\n    action.action = 'launch'; // Proceed to launch\n  } else if (action.action === 'install') {\n    console.log(chalk.red(`\\n${browserToUse.name} is not installed or not found.`));\n    const pkgInfo = browserToUse.packageName[process.platform];\n    if (pkgInfo) {\n        if (pkgInfo.startsWith('http')) {\n            console.log(chalk.cyan(`  Please download and install from: ${pkgInfo}`));\n        } else if (process.platform === 'linux') {\n            console.log(chalk.cyan(`  For example, on Ubuntu/Debian, try: sudo apt update && sudo apt install ${pkgInfo}`));\n        } else if (process.platform === 'freebsd') {\n            console.log(chalk.cyan(`  For example, try: sudo pkg install ${pkgInfo}`));\n        } else {\n            console.log(chalk.cyan(`  Please visit the ${browserToUse.name} website to download and install.`));\n        }\n    } else {\n        console.log(chalk.cyan(`  No specific installation instructions for ${browserToUse.name} on ${process.platform}. Please visit its website.`));\n    }\n    await cleanup(`${browserToUse.name} not installed`, null, { exit: true });\n    return;\n  }\n\n  // Ensure browserToUse has foundPath if we are launching/relaunching\n  if (action.action === 'launch' && !browserToUse.foundPath) {\n      console.error(chalk.red(`Error: Attempting to launch ${browserToUse.name}, but its executable path was not found.`));\n      console.log(chalk.yellow(`Please ensure ${browserToUse.name} is installed correctly and accessible.`));\n      await cleanup('Executable path missing for launch', null, { exit: true });\n      return;\n  }\n\n  await cleanTempCache();\n  console.log(chalk.cyan(`Launching library server...`));\n  await LibraryServer.start({ server_port });\n  console.log(chalk.green(`Library server started on port ${server_port}.`));\n\n  let launchedBrowserProcess = null;\n  if (action.action === 'launch') {\n    console.log(chalk.cyan(`Launching ${browserToUse.name} from ${browserToUse.foundPath}...`));\n    \n    const browserArgsForLaunch = [\n        ...BASE_CHROME_FLAGS, // Includes remote debugging port\n        `--user-data-dir=${path.resolve(os.homedir(), '.config', 'dosaygo', 'DN-Profile')}`,\n        // Add any browser-specific flags if needed, e.g. based on browserToUse.name\n        // For now, assuming BASE_CHROME_FLAGS are generic enough for Chromium-based ones\n        `${GO_SECURE ? 'https' : 'http'}://localhost:${server_port}` // Starting URL\n    ];\n    \n    // Remove userDataDir: false from LAUNCH_OPTS if it was there, as it's not a standard spawn option.\n    // userDataDir is typically a flag like --user-data-dir=...\n    // If you want a specific user data dir, add it to browserArgsForLaunch.\n    // For a fresh profile, many browsers do this by default if no --user-data-dir is set,\n    // or you might need a specific flag like --guest or ensure no existing profile is picked up.\n    // For now, we are not explicitly setting --user-data-dir for a throwaway profile.\n    // If args.temp_browser_profile() is desired:\n    // browserArgsForLaunch.push(`--user-data-dir=${args.temp_browser_profile()}`);\n\n\n    launchedBrowserProcess = BrowserLauncher.launch(browserToUse.foundPath, browserArgsForLaunch);\n\n    if (!launchedBrowserProcess) {\n      console.error(chalk.red(`Failed to launch ${browserToUse.name}.`));\n      await cleanup('Browser launch failed', null, { exit: true });\n      return;\n    }\n\n    launchedBrowserProcess.on('exit', async (code, signal) => {\n      const exitReason = code !== null ? `exited with code ${code}` : `killed by signal ${signal}`;\n      console.log(chalk.magenta(`Browser process (${browserToUse.name}) ${exitReason}.`));\n      if (!quitting) { // Avoid info message if we are intentionally quitting everything\n        console.info(chalk.cyan(`\n          ---------------------------------------------------------------------\n          INFO: Browser exited. If this was unexpected or too quick:\n          - Check for error messages above from the browser process.\n          - If running headless (DK_HEADLESS=true), ensure your setup is correct.\n            You might need a display server like Xvfb on Linux if not using --headless=new.\n          - The browser might have crashed or failed to start with the given flags.\n          ---------------------------------------------------------------------\n        `));\n      }\n      await cleanup(`Browser ${exitReason}`, null, { exit: true });\n    });\n\n    // Give the browser a moment to start up and open the remote debugging port\n    console.log(chalk.green(`${browserToUse.name} launched. PID: ${launchedBrowserProcess.pid}. Waiting for it to become connectable...`));\n    await sleep(2500); // Wait a bit for RDP to be available\n    \n    // Verify connectability after launch\n    const isNowConnectable = await checkIsConnectable(browserToUse);\n    if (!isNowConnectable) {\n        console.warn(chalk.yellow(`Launched ${browserToUse.name}, but it's not connectable on port ${chrome_port} after waiting.`));\n        console.warn(chalk.yellow(`Archivist might not function correctly. Check browser console for errors.`));\n        // Decide if this is a fatal error or if we should proceed with caution\n        // For now, proceed with caution.\n    } else {\n        console.log(chalk.green(`${browserToUse.name} is connectable.`));\n    }\n\n  } else if (action.action === 'connect') {\n    console.log(chalk.cyan(`Proceeding with already running and connectable ${browserToUse.name}.`));\n  }\n\n  if (quitting) return;\n\n  console.log(chalk.cyan(`Launching archivist and connecting to browser on port ${chrome_port}...`));\n  await Archivist.collect({ chrome_port, mode });\n  console.log(chalk.green.bold(`System ready. Archivist connected.`));\n}\n\nasync function cleanup(reason, err, { exit = false } = {}) {\n  if (quitting && exit) {\n    DEBUG.verbose && console.log(chalk.cyan(`Cleanup already in progress for exit. Reason: ${reason}`));\n    return;\n  }\n  console.log(chalk.cyan(`\\nInitiating shutdown sequence. Reason: ${reason}`));\n  if (err) {\n    console.error(chalk.red('Error during operation or shutdown:'), err instanceof Error ? err.stack : err);\n  }\n\n  if (exit) quitting = true;\n\n  DEBUG.verbose && console.log(chalk.yellow(`Cleanup called. Reason: ${reason}`));\n\n  Archivist.shutdown(); // Signal archivist to stop its work\n  LibraryServer.stop(); // Stop the HTTP server\n\n  // Note: We don't explicitly kill the browser here if it was launched by us.\n  // Its 'exit' handler calls cleanup. If the user chose 'connect', we don't own the process.\n  // If 'shutdown_all_and_exit' was chosen, browsers were killed before this.\n\n  if (exit) {\n    console.log(chalk.cyan(`All components signaled to stop. Exiting in 3 seconds...`));\n    await sleep(3000);\n    process.exit(err instanceof Error ? 1 : 0);\n  }\n}\n"
  },
  {
    "path": "src/archivist.js",
    "content": "// Licenses\n  // FlexSearch is Apache-2.0 licensed\n    // Source: https://github.com/nextapps-de/flexsearch/blob/bffb255b7904cb7f79f027faeb963ecef0a85dba/LICENSE\n  // NDX is MIT licensed\n    // Source: https://github.com/ndx-search/ndx/blob/cc9ec2780d88918338d4edcfca2d4304af9dc721/LICENSE\n  \n// module imports\n  import crypto from 'crypto';\n  import { rainbowHash } from '@dosyago/rainsum';\n  import {URL} from 'url';\n  import Path from 'path';\n  import os from 'os';\n  import Fs from 'fs';\n  import {stdin as input, stdout as output} from 'process';\n  import util from 'util';\n  import readline from 'readline';\n\n  // search related\n    import FlexSearch from 'flexsearch';\n    const {Index: FTSIndex} = FlexSearch;\n    //const {Index: FTSIndex} = require('flexsearch');\n    import { \n      createIndex as NDX, \n      addDocumentToIndex as ndx, \n      removeDocumentFromIndex, \n      vacuumIndex \n    } from 'ndx';\n    import { query as NDXQuery } from 'ndx-query';\n    import { toSerializable, fromSerializable } from 'ndx-serializable';\n    //import { DocumentIndex } from 'ndx';\n    import Fuzzy from 'fz-search';\n    //import * as _Fuzzy from './lib/fz.js';\n    import Nat from 'natural';\n\n  import args from './args.js';\n  import {\n    GO_SECURE,\n    untilTrue,\n    sleep, DEBUG as debug, \n    BATCH_SIZE,\n    MIN_TIME_PER_PAGE,\n    MAX_TIME_PER_PAGE,\n    MAX_TITLE_LENGTH,\n    MAX_URL_LENGTH,\n    clone,\n    CHECK_INTERVAL, TEXT_NODE, FORBIDDEN_TEXT_PARENT,\n    RichError\n  } from './common.js';\n  import {connect} from './protocol.js';\n  import {BLOCKED_CODE, BLOCKED_HEADERS} from './blockedResponse.js';\n  import {getInjection} from '../public/injection.js';\n  import {hasBookmark, bookmarkChanges} from './bookmarker.js';\n\n// search related state: constants and variables\n  const DEBUG = debug || false;\n  // common\n    /* eslint-disable no-control-regex */\n    const STRIP_CHARS = /[\\u0001-\\u001a\\0\\v\\f\\r\\t\\n]/g;\n    /* eslint-enable no-control-regex */\n    //const Fuzzy = globalThis.FuzzySearch;\n    const NDX_OLD = false;\n    const USE_FLEX = true;\n    const FTS_INDEX_DIR = args.fts_index_dir;\n    const URI_SPLIT = /[/.]/g;\n    const NDX_ID_KEY = 'ndx_id';\n    const INDEX_HIDDEN_KEYS = new Set([\n      NDX_ID_KEY\n    ]);\n    const hiddenKey = key => key.startsWith('ndx') || INDEX_HIDDEN_KEYS.has(key);\n    let Id;\n\n  // natural (NLP tools -- stemmers and tokenizers, etc)\n    const {WordTokenizer, PorterStemmer} = Nat;\n    const Tokenizer = new WordTokenizer();\n    const Stemmer = PorterStemmer;\n    const words = Tokenizer.tokenize.bind(Tokenizer);\n    const termFilter = Stemmer.stem.bind(Stemmer);\n    //const termFilter = s => s.toLocaleLowerCase();\n\n  // FlexSearch\n    const FLEX_OPTS = {\n      charset: \"utf8\",\n      context: true,\n      language: \"en\",\n      tokenize: \"reverse\"\n    };\n    let Flex = new FTSIndex(FLEX_OPTS);\n    DEBUG.verboseSlow && console.log({Flex});\n\n  // NDX\n    const NDXRemoved = new Set();\n    const REMOVED_CAP_TO_VACUUM_NDX = 10;\n    const NDX_FIELDS = ndxDocFields();\n    let NDX_FTSIndex = new NDXIndex(NDX_FIELDS);\n    let NDXId;\n    DEBUG.verboseSlow && console.log({NDX_FTSIndex});\n\n  // fuzzy (maybe just for queries ?)\n    const REGULAR_SEARCH_OPTIONS_FUZZY = {\n      minimum_match: 1.0\n    };\n    const HIGHLIGHT_OPTIONS_FUZZY = {\n      minimum_match: 2.0 // or 3.0 seems to be good\n    };\n    const FUZZ_OPTS = {\n      keys: ndxDocFields({namesOnly:true})\n    };\n    const Docs = new Map();\n    let fuzzy = new Fuzzy({source: [...Docs.values()], keys: FUZZ_OPTS.keys});\n\n// module state: constants and variables\n  // cache is a simple map\n    // that holds the serialized requests\n    // that are saved on disk\n  const Status = {\n    loaded: false\n  };\n  const FrameNodes = new Map();\n  const Targets = new Map();\n  const UpdatedKeys = new Set();\n  const Cache = new Map();\n  const Index = new Map();\n  const Indexing = new Set();\n  const CrawlIndexing = new Set();\n  const CrawlTargets = new Set();\n  const CrawlData = new Map();\n  const Q = new Set();\n  const Sessions = new Map();\n  const Installations = new Set();\n  const ConfirmedInstalls = new Set();\n  const BLANK_STATE = {\n    Targets,\n    Sessions,\n    Installations,\n    ConfirmedInstalls,\n    FrameNodes,\n    Docs,\n    Indexing,\n    CrawlIndexing,\n    CrawlData,\n    CrawlTargets,\n    Cache, \n    Index,\n    NDX_FTSIndex,\n    Flex,\n    SavedCacheFilePath: null,\n    SavedIndexFilePath: null,\n    SavedFTSIndexDirPath: null,\n    SavedFuzzyIndexDirPath: null,\n    saver: null,\n    indexSaver: null,\n    ftsIndexSaver: null,\n    saveInProgress: false,\n    ftsSaveInProgress: false\n  };\n  const State = Object.assign({}, BLANK_STATE);\n  export const Archivist = { \n    NDX_OLD,\n    USE_FLEX,\n    collect, getMode, changeMode, shutdown, \n    beforePathChanged,\n    afterPathChanged,\n    saveIndex,\n    getIndex,\n    deleteFromIndexAndSearch,\n    search,\n    getDetails,\n    isReady,\n    findOffsets,\n    archiveAndIndexURL\n  }\n  const BODYLESS = new Set([\n    301,\n    302,\n    303,\n    307\n  ]);\n  const NEVER_CACHE = new Set([\n    `${GO_SECURE ? 'https://localhost' : 'http://127.0.0.1'}:${args.server_port}`,\n    `http://localhost:${args.server_port}`,\n    `http://localhost:${args.chrome_port}`,\n    `http://127.0.0.1:${args.chrome_port}`,\n    `https://127.0.0.1:${args.chrome_port}`,\n    `http://::1:${args.chrome_port}`,\n    `https://::1:${args.chrome_port}`\n  ]);\n  const SORT_URLS = ([urlA],[urlB]) => urlA < urlB ? -1 : 1;\n  const CACHE_FILE = args.cache_file; \n  const INDEX_FILE = args.index_file;\n  const NO_FILE = args.no_file;\n  const TBL = /(:\\/\\/|:|@)/g;\n  const UNCACHED_BODY = b64('We have not saved this data');\n  const UNCACHED_CODE = 404;\n  const UNCACHED_HEADERS = [\n    { name: 'Content-type', value: 'text/plain' },\n    { name: 'Content-length', value: '26' }\n  ];\n  const UNCACHED = {\n    body:UNCACHED_BODY, responseCode:UNCACHED_CODE, responseHeaders:UNCACHED_HEADERS\n  }\n  let Mode, Close;\n\n// shutdown and cleanup\n  // handle writing out indexes and closing browser connection when resetting under nodemon\n    process.once('SIGUSR2', function () {\n      shutdown(function () {\n        process.kill(process.pid, 'SIGUSR2');\n      });\n    });\n\n// logging\n    let logName;\n    let logStream;\n\n// main\n  async function collect({chrome_port:port, mode} = {}) {\n    try {\n      console.log('Starting collect');\n      const {library_path} = args;\n      const exitHandlers = [];\n      process.on('beforeExit', runHandlers);\n      process.on('SIGUSR2', code => runHandlers(code, 'SIGUSR2', {exit: true}));\n      process.on('exit', code => runHandlers(code, 'exit', {exit: true}));\n      State.connection = State.connection || await connect({port});\n      console.log('Connection established');\n      State.onExit = {\n        addHandler(h) {\n          exitHandlers.push(h);\n        }\n      };\n      const {send, on, close} = State.connection;\n      //const DELAY = 100; // 500 ?\n      Close = close;\n\n      let requestStage;\n      \n      console.log('Loading files...');\n      await loadFiles();\n\n      clearSavers();\n\n      Mode = mode; \n      console.log({Mode});\n      if ( Mode == 'save' || Mode == 'select' ) {\n        requestStage = \"Response\";\n        // in case we get a updateBasePath call before an interval\n        // and we don't clear it in time, leading us to erroneously save the old\n        // cache to the new path, we always used our saved copy\n        State.saver = setInterval(() => saveCache(State.SavedCacheFilePath), 17000);\n        // we use timeout because we can trigger this ourself\n        // so in order to not get a race condition (overlapping calls) we ensure \n        // only 1 call at 1 time\n        State.indexSaver = setTimeout(() => saveIndex(State.SavedIndexFilePath), 11001);\n        State.ftsIndexSaver = setTimeout(() => saveFTS(State.SavedFTSIndexDirPath), 31001);\n      } else if ( Mode == 'serve' ) {\n        requestStage = \"Request\";\n        clearSavers();\n      } else {\n        throw new TypeError(`Must specify mode, and must be one of: save, serve, select`);\n      }\n\n      on(\"Target.targetInfoChanged\", attachToTarget);\n      on(\"Target.targetInfoChanged\", updateTargetInfo);\n      on(\"Target.targetInfoChanged\", indexURL);\n      on(\"Target.attachedToTarget\", installForSession);\n      on(\"Page.loadEventFired\", reloadIfNotLive);\n      on(\"Fetch.requestPaused\", cacheRequest);\n      on(\"Runtime.consoleAPICalled\", handleMessage);\n\n      await send(\"Target.setDiscoverTargets\", {discover:true});\n      await send(\"Target.setAutoAttach\", {autoAttach:true, waitForDebuggerOnStart:false, flatten: true});\n      await send(\"Security.setIgnoreCertificateErrors\", {ignore:true});\n      await send(\"Fetch.enable\", {\n        patterns: [\n          {\n            urlPattern: \"http*://*\", \n            requestStage\n          }\n        ], \n      });\n\n      const {targetInfos:targets} = await send(\"Target.getTargets\", {});\n      DEBUG.debug && console.log({targets});\n      const pageTargets = targets.filter(({type}) => type == 'page').map(targetInfo => ({targetInfo}));\n      await Promise.all(pageTargets.map(attachToTarget));\n      sleep(5000).then(() => Promise.all(pageTargets.map(reloadIfNotLive)));\n\n      State.bookmarkObserver = State.bookmarkObserver || startObservingBookmarkChanges();\n\n      Status.loaded = true;\n\n      return Status.loaded;\n\n      async function runHandlers(reason, err, {exit = false} = {}) {\n        debug.verbose && console.log('before exit running', exitHandlers, {reason, err});\n        while(exitHandlers.length) {\n          const h = exitHandlers.shift();\n          try {\n            h();\n          } catch(e) {\n            console.warn(`Error in exit handler`, h, e);\n          }\n        }\n        if ( exit ) {\n          console.log(`Exiting in 3 seconds...`);\n          await sleep(3000);\n          process.exit(0);\n        }\n      }\n\n      function handleMessage(args) {\n        const {type, args:[{value:strVal}]} = args;\n        if ( type == 'info' ) {\n          try {\n            const val = JSON.parse(strVal);\n            // possible messages\n            const {install, titleChange, textChange} = val;\n            switch(true) {\n              case !!install: {\n                  confirmInstall({install});\n                } break;\n              case !!titleChange: {\n                  reindexOnContentChange({titleChange});\n                } break;\n              case !!textChange: {\n                  reindexOnContentChange({textChange});\n                } break;\n              default: {\n                  if ( DEBUG ) {\n                    console.warn(`Unknown message`, strVal);\n                  }\n                } break;\n            }\n          } catch(e) {\n            DEBUG.verboseSlow && console.info('Not the message we expected to confirm install. This is OK.', {originalMessage:args});\n          } \n        }\n      }\n\n      function confirmInstall({install}) {\n        const {sessionId} = install;\n        if ( ! State.ConfirmedInstalls.has(sessionId) ) {\n          State.ConfirmedInstalls.add(sessionId);\n          DEBUG.verboseSlow && console.log({confirmedInstall:install});\n        }\n      }\n\n      async function reindexOnContentChange({titleChange, textChange}) {\n        const data = titleChange || textChange;\n        if ( data ) {\n          const {sessionId} = data;\n          const latestTargetInfo = clone(await untilHas(Targets, sessionId));\n          if ( titleChange ) {\n            const {currentTitle} = titleChange;\n            DEBUG.verboseSlow && console.log('Received titleChange', titleChange);\n            latestTargetInfo.title = currentTitle;\n            Targets.set(sessionId, latestTargetInfo);\n            DEBUG.verboseSlow && console.log('Updated stored target info', latestTargetInfo);\n          } else {\n            DEBUG.verboseSlow && console.log('Received textChange', textChange);\n          }\n          if ( ! dontCache(latestTargetInfo) ) {\n            DEBUG.verboseSlow && console.log(\n              `Will reindex because we were told ${titleChange ? 'title' : 'text'} content maybe changed.`, \n              data\n            );\n            indexURL({targetInfo:latestTargetInfo});\n          }\n        }\n      }\n\n      function updateTargetInfo({targetInfo}) {\n        if ( targetInfo.type === 'page' ) {\n          const sessionId = State.Sessions.get(targetInfo.targetId); \n          DEBUG.verboseSlow && console.log('Updating target info', targetInfo, sessionId);\n          if ( sessionId ) {\n            const existingTargetInfo = Targets.get(sessionId);\n            // if we have an existing target info for this URL and have saved an updated title\n            DEBUG.verboseSlow && console.log('Existing target info', existingTargetInfo);\n            if ( existingTargetInfo && existingTargetInfo.url === targetInfo.url ) {\n              // keep that title (because targetInfo does not reflect the latest title)\n              if ( existingTargetInfo.title !== existingTargetInfo.url ) {\n                DEBUG.verboseSlow && console.log('Setting title to existing', existingTargetInfo);\n                targetInfo.title = existingTargetInfo.title;\n              }\n            }\n            Targets.set(sessionId, clone(targetInfo));\n          }\n        }\n      }\n\n      async function reloadIfNotLive({targetInfo, sessionId} = {}) {\n        if ( Mode == 'serve' ) return; \n        if ( !targetInfo && !!sessionId ) {\n          targetInfo = Targets.get(sessionId);\n          console.log(targetInfo);\n        }\n        if ( neverCache(targetInfo?.url) ) return;\n        const {attached, type} = targetInfo;\n        if ( attached && type == 'page' ) {\n          const {url, targetId} = targetInfo;\n          const sessionId = State.Sessions.get(targetId);\n          if ( !!sessionId && !State.ConfirmedInstalls.has(sessionId) ) {\n            DEBUG.verboseSlow && console.log({\n              reloadingAsNotConfirmedInstalled:{\n                url, \n                sessionId\n              },\n              confirmedInstalls: State.ConfirmedInstalls\n            });\n            await sleep(600);\n            send(\"Page.stopLoading\", {}, sessionId);\n            send(\"Page.reload\", {}, sessionId);\n          }\n        }\n      }\n\n      function neverCache(url) {\n        if ( ! url ) return true;\n        try {\n          url = new URL(url);\n          return url?.href == \"about:blank\" || url?.href?.startsWith('chrome') || NEVER_CACHE.has(url.origin);\n        } catch(e) {\n          DEBUG.debug && console.warn('Could not form url', url, e);\n          return true;\n        } \n      }\n\n      async function installForSession({sessionId, targetInfo, waitingForDebugger}) {\n        if ( waitingForDebugger ) {\n          console.warn(targetInfo);\n          throw new TypeError(`Target not ready for install`);\n        }\n        if ( ! sessionId ) {\n          throw new TypeError(`installForSession needs a sessionId`);\n        }\n\n        const {targetId, url} = targetInfo;\n\n        const installUneeded = dontInstall(targetInfo) ||\n          State.Installations.has(sessionId)\n        ;\n\n        if ( installUneeded ) return;\n\n        DEBUG.verboseSlow && console.log(\"installForSession running on target \" + targetId);\n\n        State.Sessions.set(targetId, sessionId);\n        Targets.set(sessionId, clone(targetInfo));\n\n        if ( Mode == 'save' || Mode == 'select' ) {\n          send(\"Network.setCacheDisabled\", {cacheDisabled:true}, sessionId);\n          send(\"Network.setBypassServiceWorker\", {bypass:true}, sessionId);\n\n          await send(\"Runtime.enable\", {}, sessionId);\n          await send(\"Page.enable\", {}, sessionId);\n          await send(\"Page.setAdBlockingEnabled\", {enabled: true}, sessionId);\n          await send(\"DOMSnapshot.enable\", {}, sessionId);\n\n          on(\"Page.frameNavigated\", updateFrameNode);\n          on(\"Page.frameAttached\", addFrameNode);\n          // on(\"Page.frameDetached\", updateFrameNodes); // necessary? maybe not \n\n          await send(\"Page.addScriptToEvaluateOnNewDocument\", {\n            source: getInjection({sessionId}),\n            worldName: \"Context-22120-Indexing\",\n            runImmediately: true\n          }, sessionId);\n\n          DEBUG.verboseSlow && console.log(\"Just request install\", targetId, url);\n        }\n\n        State.Installations.add(sessionId);\n\n        DEBUG.verboseSlow && console.log('Installed sessionId', sessionId);\n        if ( Mode == 'save' ) {\n          indexURL({targetInfo});\n        }\n      }\n\n      async function indexURL({targetInfo:info = {}, sessionId, waitingForDebugger} = {}) {\n        if ( waitingForDebugger ) {\n          console.warn(info);\n          throw new TypeError(`Target not ready for install`);\n        }\n        if ( Mode == 'serve' ) return;\n        if ( info.type != 'page' ) return;\n        if ( ! info.url  || info.url == 'about:blank' ) return;\n        if ( info.url.startsWith('chrome') ) return;\n        if ( dontCache(info) ) return;\n\n        DEBUG.verboseSlow && console.log('Index URL', info);\n\n        DEBUG.verboseSlow && console.log('Index URL called', info);\n\n        if ( State.Indexing.has(info.targetId) ) return;\n        State.Indexing.add(info.targetId);\n\n        if ( ! sessionId ) {\n          sessionId = await untilHas(\n            State.Sessions, info.targetId, \n            {timeout: State.crawling && State.crawlTimeout}\n          );\n        }\n\n        if ( !State.Installations.has(sessionId) ) {\n          await untilHas(\n            State.Installations, sessionId, \n            {timeout: State.crawling && State.crawlTimeout}\n          );\n        }\n\n        send(\"DOMSnapshot.enable\", {}, sessionId);\n\n        await sleep(500);\n\n        const flatDoc = await send(\"DOMSnapshot.captureSnapshot\", {\n          computedStyles: [],\n        }, sessionId);\n        const pageText = processDoc(flatDoc).replace(STRIP_CHARS, ' ');\n\n        if ( State.crawling ) {\n          const has = await untilTrue(() => State.CrawlData.has(info.targetId));\n\n          const {url} = Targets.get(sessionId);\n          if ( ! dontCache({url}) ) {\n            if ( has ) {\n              const {depth,links} = State.CrawlData.get(info.targetId);\n              DEBUG.verboseSlow && console.log(info, {depth,links});\n\n              const {result:{value:{title,links:crawlLinks}}} = await send(\"Runtime.evaluate\", {\n                expression: `(function () { \n                  return {\n                    links: Array.from(\n                      document.querySelectorAll('a[href]')\n                    ).map(a => a.href),\n                    title: document.title\n                  };\n                }())`,\n                returnByValue: true\n              }, sessionId);\n\n              const shouldCrawl = depth <= State.crawlDepth;\n\n              if ( shouldCrawl ) {\n                links.length = 0;\n                links.push(...crawlLinks.filter(url => url.startsWith('http')).map(url => ({url,depth:depth+1})));\n              }\n              if ( logStream ) {\n                console.log(`Writing ${links.length} entries to ${logName}`);\n                logStream.cork();\n                links.forEach(url => {\n                  logStream.write(`${url}\\n`);\n                });\n                logStream.uncork();\n              }\n              console.log(`Just crawled: ${title} (${info.url})`);\n            }\n\n            if ( ! State.titles ) {\n              State.titles = new Map();\n              State.onExit.addHandler(() => {\n                Fs.writeFileSync(\n                  Path.resolve(args.CONFIG_DIR, `titles-${(new Date).toISOString()}.txt`), \n                  JSON.stringify([...State.titles.entries()], null, 2) + '\\n'\n                );\n              });\n            }\n\n            const {result:{value:data}} = await send(\"Runtime.evaluate\", \n              {\n                expression: `(function () {\n                  return {\n                    url: document.location.href,\n                    title: document.title,\n                  };\n                }())`,\n                returnByValue: true\n              }, \n              sessionId\n            );\n\n            State.titles.set(data.url, data.title);\n            console.log(`Saved ${State.titles.size} titles`);\n\n            if ( State.program && ! dontCache(info) ) {\n              const targetInfo = info;\n              const fs = Fs;\n              const path = Path;\n              try {\n                await sleep(500);\n                await eval(`(async () => {\n                  try {\n                    ${State.program}\n                  } catch(e) {\n                    console.warn('Error in program', e, State.program);\n                  }\n                })();`);\n                await sleep(500);\n              } catch(e) {\n                console.warn(`Error evaluate program`, e);\n              }\n            }\n          }\n        }\n\n        const {title, url} = Targets.get(sessionId);\n        let id, ndx_id;\n        if ( State.Index.has(url) ) {\n          ({ndx_id, id} = State.Index.get(url));\n        } else {\n          Id++;\n          id = Id;\n        }\n        const doc = toNDXDoc({id, url, title, pageText});\n        State.Index.set(url, {date:Date.now(),id:doc.id, ndx_id:doc.ndx_id, title});   \n        State.Index.set(doc.id, url);\n        State.Index.set('ndx'+doc.ndx_id, url);\n\n        const contentSignature = getContentSig(doc);\n\n        //Flex code\n        Flex.update(doc.id, contentSignature);\n\n        //New NDX code\n        NDX_FTSIndex.update(doc, ndx_id);\n\n        // Fuzzy \n        // eventually we can use this update logic for everyone\n        let updateFuzz = true;\n        if ( State.Docs.has(url) ) {\n          const current = State.Docs.get(url);\n          if ( current.contentSignature === contentSignature ) {\n            updateFuzz = false;\n          }\n        }\n        if ( updateFuzz ) {\n          doc.contentSignature = contentSignature;\n          fuzzy.add(doc);\n          State.Docs.set(url, doc);\n          DEBUG.verboseSlow && console.log({updateFuzz: {doc,url}});\n        }\n\n        DEBUG.verboseSlow && console.log(\"NDX updated\", doc.ndx_id);\n\n        UpdatedKeys.add(url);\n\n        DEBUG.verboseSlow && console.log({id: doc.id, title, url, indexed: true});\n\n        State.Indexing.delete(info.targetId);\n        State.CrawlIndexing.delete(info.targetId);\n      }\n\n      async function attachToTarget({targetInfo}, retryCount = 0) {\n        if ( dontInstall(targetInfo) ) return;\n        const {url} = targetInfo;\n        if ( url && targetInfo.type == 'page' ) {\n          try {\n            if ( ! targetInfo.attached ) {\n              const {sessionId} = (await send(\"Target.attachToTarget\", {\n                targetId: targetInfo.targetId,\n                flatten: true\n              }));\n              State.Sessions.set(targetInfo.targetId, sessionId);\n            }\n          } catch(e) {\n            DEBUG.verboseSlow && console.error(`Attach to target failed`, targetInfo);\n            if ( retryCount < 3 ) {\n              const ms = 1500;\n              DEBUG.verboseSlow && console.log(`Retrying attach in ${ms/1000} seconds...`);\n              setTimeout(() => attachToTarget({targetInfo}, (retryCount || 1) + 1), ms);\n            } \n          }\n        }\n      }\n\n      async function cacheRequest(pausedRequest) {\n        const {\n          requestId, request, resourceType, \n          frameId,\n          responseStatusCode, responseHeaders, responseErrorReason\n        } = pausedRequest;\n        const isNavigationRequest = resourceType == \"Document\";\n        const isFont = resourceType == \"Font\";\n\n        if ( dontCache(request) ) {\n          DEBUG.verboseSlow && console.log(\"Not caching\", request.url);\n          send(`Fetch.continue${requestStage}`, {requestId});\n          return;\n        }\n        const key = serializeRequestKey(request);\n        if ( Mode == 'serve' ) {\n          if ( State.Cache.has(key) ) {\n            let {body, responseCode, responseHeaders} = await getResponseData(State.Cache.get(key));\n            responseCode = responseCode || 200;\n            //DEBUG.verboseSlow && console.log(\"Fulfilling\", key, responseCode, responseHeaders, body.slice(0,140));\n            DEBUG.verboseSlow && console.log(\"Fulfilling\", key, responseCode, body.slice(0,140));\n            await send(\"Fetch.fulfillRequest\", {\n              requestId, body, responseCode, responseHeaders\n            });\n          } else {\n            DEBUG.verboseSlow && console.log(\"Sending cache stub\", key);\n            await send(\"Fetch.fulfillRequest\", {\n              requestId, ...UNCACHED\n            });\n          } \n        } else {\n          let saveIt = false;\n          if ( Mode == 'select' ) {\n            const rootFrameURL = getRootFrameURL(frameId);\n            const frameDescendsFromBookmarkedURLFrame = hasBookmark(rootFrameURL);\n            saveIt = frameDescendsFromBookmarkedURLFrame;\n            DEBUG.verboseSlow && console.log({rootFrameURL, frameId, mode, saveIt});\n          } else if ( Mode == 'save' ) {\n            saveIt = true;\n          }\n          if ( saveIt ) {\n            const response = {key, responseCode: responseStatusCode, responseHeaders};\n            const resp = await getBody({requestId, responseStatusCode});\n            if ( resp ) {\n              let {body, base64Encoded} = resp;\n              if ( ! base64Encoded ) {\n                body = b64(body);\n              }\n              response.body = body;\n              const responsePath = await saveResponseData(key, request.url, response);\n              State.Cache.set(key, responsePath);\n            } else {\n              DEBUG.verboseSlow && console.warn(\"get response body error\", key, responseStatusCode, responseHeaders, pausedRequest.responseErrorReason);  \n              response.body = '';\n            }\n            //await sleep(DELAY);\n            if ( !isFont && responseErrorReason ) {\n              if ( isNavigationRequest ) {\n                await send(\"Fetch.fulfillRequest\", {\n                    requestId,\n                    responseHeaders: BLOCKED_HEADERS,\n                    responseCode: BLOCKED_CODE,\n                    body: Buffer.from(responseErrorReason).toString(\"base64\"),\n                  },\n                );\n              } else {\n                await send(\"Fetch.failRequest\", {\n                    requestId,\n                    errorReason: responseErrorReason\n                  },\n                );\n              }\n              return;\n            }\n          } \n          send(`Fetch.continue${requestStage}`, {requestId}).catch(\n            e => console.warn(\"Issue with continuing request\", {e, requestStage, requestId})\n          );\n        }\n      }\n\n      async function getBody({requestId, responseStatusCode}) {\n        let resp;\n        if ( ! BODYLESS.has(responseStatusCode) ) {\n          resp = await send(\"Fetch.getResponseBody\", {requestId});\n        } else {\n          resp = {body:'', base64Encoded:true};\n        }\n        return resp;\n      }\n      \n      function dontInstall(targetInfo) {\n        return targetInfo.type !== 'page';\n      }\n\n      async function getResponseData(path) {\n        try {\n          return JSON.parse(await Fs.promises.readFile(path));\n        } catch(e) {\n          console.warn(`Error with ${path}`, e);\n          return UNCACHED;\n        }\n      }\n\n      async function saveResponseData(key, url, response) {\n        try {\n          const origin = (new URL(url).origin);\n          let originDir = State.Cache.get(origin);\n          if ( ! originDir ) {\n            originDir = Path.resolve(library_path(), origin.replace(TBL, '_'));\n            try {\n              Fs.mkdirSync(originDir, {recursive:true});\n            } catch(e) {\n              console.warn(`Issue with origin directory ${originDir}`, e);\n            }\n            State.Cache.set(origin, originDir);\n          } else {\n            if ( originDir.includes(':\\\\\\\\') ) {\n              originDir = originDir.split(/:\\\\\\\\/, 2);\n              originDir[1] = originDir[1]?.replace?.(TBL, '_');\n              originDir = originDir.join(':\\\\\\\\');\n            }\n          }\n\n          const fileName = `${await sha1(key)}.json`;\n\n          const responsePath = Path.resolve(originDir, fileName);\n          try {\n            await Fs.promises.writeFile(responsePath, JSON.stringify(response,null,2));\n          } catch(e) {\n            console.warn(`Issue with origin directory or file: ${responsePath}`, e);\n          }\n\n          return responsePath;\n        } catch(e) {\n          console.warn(`Could not save response data`, e);\n          return '';\n        }\n      }\n\n      async function sha1(key) {\n        return crypto.createHash('sha1').update(key).digest('hex');\n      }\n      \n      async function rainbow(key) {\n        return rainbowHash(128, 0, new Uint8Array(Buffer.from(key)));\n      }\n\n      function serializeRequestKey(request) {\n        const {url, /*urlFragment,*/ method, /*headers, postData, hasPostData*/} = request;\n\n        /**\n        let sortedHeaders = '';\n        for( const key of Object.keys(headers).sort() ) {\n          sortedHeaders += `${key}:${headers[key]}/`;\n        }\n        **/\n\n        return `${method}${url}`;\n        //return `${url}${urlFragment}:${method}:${sortedHeaders}:${postData}:${hasPostData}`;\n      }\n\n      async function startObservingBookmarkChanges() {\n        console.info(\"Not observing\");\n        return;\n        for await ( const change of bookmarkChanges() ) {\n          if ( Mode == 'select' ) {\n            switch(change.type) {\n              case 'new': {\n                  DEBUG.verboseSlow && console.log(change);\n                  archiveAndIndexURL(change.url);\n                } break;\n              case 'delete': {\n                  DEBUG.verboseSlow && console.log(change);\n                  deleteFromIndexAndSearch(change.url);\n                } break;\n              default: {\n                console.log(`We don't do anything about this bookmark change, currently`, change);\n              } break;\n            }\n          }\n        }\n      }\n    } catch(e) {\n      console.error('Error while collect', e);\n    }\n  }\n\n// helpers\n  function neverCache(url) {\n    return !url || url == \"about:blank\" || url.match(/^(?:chrome|vivaldi|brave|edge)/) || NEVER_CACHE.has(url);\n  }\n\n  function dontCache(request) {\n    if ( ! request.url ) return true;\n    if ( neverCache(request.url) ) return true;\n    if ( Mode == 'select' && ! hasBookmark(request.url) ) return true;\n    const url = new URL(request.url);\n    return NEVER_CACHE.has(url.origin) || !!(State.No && State.No.test(url.host));\n  }\n\n  function processDoc({documents, strings}) {\n    /* \n      Info\n      Implementation Notes \n\n      1. Code uses spec at: \n        https://chromedevtools.github.io/devtools-protocol/tot/DOMSnapshot/#type-NodeTreeSnapshot\n\n      2. Note that so far the below will NOT produce text for and therefore we will NOT\n      index textarea or input elements. We can access those by using the textValue and\n      inputValue array properties of the doc, if we want to implement that.\n    */\n       \n    const texts = [];\n    for( const doc of documents) {\n      const textIndices = doc.nodes.nodeType.reduce((Indices, type, index) => {\n        if ( type === TEXT_NODE ) {\n          const parentIndex = doc.nodes.parentIndex[index];\n          const forbiddenParent = parentIndex >= 0 && \n            FORBIDDEN_TEXT_PARENT.has(strings[\n              doc.nodes.nodeName[\n                parentIndex\n              ]\n            ])\n          if ( ! forbiddenParent ) {\n            Indices.push(index);\n          }\n        }\n        return Indices;\n      }, []);\n      textIndices.forEach(index => {\n        const stringsIndex = doc.nodes.nodeValue[index];\n        if ( stringsIndex >= 0 ) {\n          const text = strings[stringsIndex];\n          texts.push(text);\n        }\n      });\n    }\n\n    const pageText = texts.filter(t => t.trim()).join(' ');\n    DEBUG.verboseSlow && console.log('Page text>>>', pageText);\n    return pageText;\n  }\n\n  async function isReady() {\n    return await untilTrue(() => Status.loaded);\n  }\n\n  async function loadFuzzy({fromMemOnly: fromMemOnly = false} = {}) {\n    if ( ! fromMemOnly ) {\n      const fuzzyDocs = Fs.readFileSync(getFuzzyPath()).toString();\n      State.Docs = new Map(JSON.parse(fuzzyDocs).map(doc => {\n        doc.i_url = getURI(doc.url);\n        doc.contentSignature = getContentSig(doc);\n        return [doc.url, doc];\n      }));\n    }\n    State.Fuzzy = fuzzy = new Fuzzy({source: [...State.Docs.values()], keys: FUZZ_OPTS.keys});\n    DEBUG.verboseSlow && console.log('Fuzzy loaded');\n  }\n\n  function getContentSig(doc) { \n    return doc.title + ' ' + doc.title + ' ' + doc.content + ' ' + getURI(doc.url);\n  }\n\n  function getURI(url) {\n    return url.split(URI_SPLIT).join(' ');\n  }\n\n  function saveFuzzy(basePath) {\n    const docs = [...State.Docs.values()]\n      .map(({url, title, content, id}) => ({url, title, content, id}));\n    if ( docs.length === 0 ) return;\n    const path = getFuzzyPath(basePath);\n    Fs.writeFileSync(\n      path,\n      JSON.stringify(docs, null, 2)\n    );\n    DEBUG.verboseSlow && console.log(`Wrote fuzzy to ${path}`);\n  }\n\n  function clearSavers() {\n    if ( State.saver ) {\n      clearInterval(State.saver);\n      State.saver = null;\n    }\n\n    if ( State.indexSaver ) {\n      clearTimeout(State.indexSaver);\n      State.indexSaver = null;\n    }\n\n    if ( State.ftsIndexSaver ) {\n      clearTimeout(State.ftsIndexSaver);\n      State.ftsIndexSaver = null;\n    }\n  }\n\n  async function loadFiles() {\n    let cacheFile = CACHE_FILE();\n    let indexFile = INDEX_FILE();\n    let ftsDir = FTS_INDEX_DIR();\n    let someError = false;\n\n    try {\n      State.Cache = new Map(JSON.parse(Fs.readFileSync(cacheFile)));\n    } catch(e) {\n      console.warn(e+'');\n      State.Cache = new Map();\n      someError = true;\n    }\n\n    try {\n      State.Index = new Map(JSON.parse(Fs.readFileSync(indexFile)));\n    } catch(e) {\n      console.warn(e+'');\n      State.Index = new Map();\n      someError = true;\n    }\n\n    try {\n      const flexBase = getFlexBase();\n      Fs.readdirSync(flexBase, {withFileTypes:true}).forEach(dirEnt => {\n        if ( dirEnt.isFile() ) {\n          const content = Fs.readFileSync(Path.resolve(flexBase, dirEnt.name)).toString();\n          Flex.import(dirEnt.name, JSON.parse(content));\n        }\n      });\n      DEBUG.verboseSlow && console.log('Flex loaded');\n    } catch(e) {\n      console.warn(e+'');\n      someError = true;\n    }\n\n    try {\n      loadNDXIndex(NDX_FTSIndex);\n    } catch(e) {\n      console.warn(e+'');\n      someError = true;\n    }\n\n    try {\n      await loadFuzzy();\n    } catch(e) {\n      console.warn(e+'');\n      someError = true;\n    }\n\n    if ( someError ) {\n      const rl = readline.createInterface({input, output});\n      const question = util.promisify(rl.question).bind(rl);\n      console.warn('Error reading archive file. Your archive directory is corrupted. We will attempt to patch it so you can use it going forward, but because we replace a missing or corrupt index, cache, or full-text search index files with new blank copies, existing resources already indexed and cached may become inaccessible from your new index. A future version of this software should be able to more completely repair your archive directory, reconnecting and re-existing all cached resources and notifying you about and purging from the index any missing resources.\\n');\n      console.log('Sorry about this, we are not sure why this happened, but we know this must be very distressing for you.\\n');\n      console.log(`For your information, the corruped archive directory is at: ${args.getBasePath()}\\n`);\n      console.info('Because this repair as described above is not a perfect solution, we will give you a choice of how to proceed. You have two options: 1) attempt a basic repair that may leave some resources inaccessible from the repaired archive, or 2) do not touch the corrupted archive, but instead create a new fresh blank archive to begin saving to. Which option would you like to proceed with?');\n      console.log('1) Basic repair with possible inaccessible pages');\n      console.log('2) Leave the corrupt archive untouched, start a new archive');\n      let correctAnswer = false;\n      let newBasePath = '';\n      while(!correctAnswer) {\n        let answer = await question('Which option would you like (1 or 2)? ');\n        answer = parseInt(answer);\n        switch(answer) {\n          case 1: {\n            console.log('Alright, selecting option 1. Using the existing archive and patching a simple repair.');\n            newBasePath = args.getBasePath();\n            correctAnswer = true;\n          } break;\n          case 2: {\n            console.log('Alright, selection option 2. Leaving the existing archive along and creating a new, fresh, blank archive.');\n            let correctAnswer2 = false;\n            while( ! correctAnswer2 ) {\n              try {\n                newBasePath = Path.resolve(os.homedir(), await question(\n                  'Please enter a directory name for your new archive.\\n' +\n                  `${os.homedir()}/`\n                ));\n                correctAnswer2 = true;\n              } catch(e2) {\n                console.warn(e2);\n                console.info('Sorry that was not a valid directory name.');\n                await question('enter to continue');\n              }\n            }\n            correctAnswer = true;\n          } break;\n          default: {\n            correctAnswer = false;\n            console.log('Sorry, that was not a valid option. Please input 1 or 2.');\n          } break;\n        }\n      }\n      console.log('Resetting base path', newBasePath);\n      args.updateBasePath(newBasePath, {force:true, before: [\n        () => Archivist.beforePathChanged(newBasePath, {force:true})\n      ]});\n      saveFiles({forceSave:true});\n    }\n\n    Id = Math.round(State.Index.size / 2) + 3;\n    NDXId = State.Index.has(NDX_ID_KEY) ? State.Index.get(NDX_ID_KEY) + 1003000 : (Id + 1000000);\n    if ( !Number.isInteger(NDXId) ) NDXId = Id;\n    DEBUG.verboseSlow && console.log({firstFreeId: Id, firstFreeNDXId: NDXId});\n\n    State.SavedCacheFilePath = cacheFile;\n    State.SavedIndexFilePath = indexFile;\n    State.SavedFTSIndexDirPath = ftsDir;\n    DEBUG.verboseSlow && console.log(`Loaded cache key file ${cacheFile}`);\n    DEBUG.verboseSlow && console.log(`Loaded index file ${indexFile}`);\n    DEBUG.verboseSlow && console.log(`Need to load FTS index dir ${ftsDir}`);\n\n    try {\n      if ( !Fs.existsSync(NO_FILE()) ) {\n        DEBUG.verboseSlow && console.log(`The 'No file' (${NO_FILE()}) does not exist, ignoring...`); \n        State.No = null;\n      } else {\n        State.No = new RegExp(JSON.parse(Fs.readFileSync(NO_FILE))\n          .join('|')\n          .replace(/\\./g, '\\\\.')\n          .replace(/\\*/g, '.*')\n          .replace(/\\?/g, '.?')\n        );\n      }\n    } catch(e) {\n      DEBUG.verboseSlow && console.warn('Error compiling regex from No file', e);\n      State.No = null;\n    }\n  }\n\n  function getMode() { return Mode; }\n\n  function saveFiles({useState: useState = false, forceSave:forceSave = false} = {}) {\n    if ( State.Index.size === 0 ) return;\n    clearSavers();\n    State.Index.set(NDX_ID_KEY, NDXId);\n    if ( useState ) {\n      // saves the old cache path\n      saveCache(State.SavedCacheFilePath);\n      saveIndex(State.SavedIndexFilePath);\n      saveFTS(State.SavedFTSIndexDirPath, {forceSave});\n    } else {\n      saveCache();\n      saveIndex();\n      saveFTS(null, {forceSave});\n    }\n  }\n\n  async function changeMode(mode) { \n    saveFiles({forceSave:true});\n    Mode = mode;\n    await collect({chrome_port:args.chrome_port, mode});\n    DEBUG.verboseSlow && console.log('Mode changed', Mode);\n  }\n\n  function getDetails(id) {\n    const url = State.Index.get(id);\n    const {title} = State.Index.get(url);\n    const {content} = State.Docs.get(url);\n    return {url, title, id, content};\n  }\n\n  function findOffsets(query, doc, maxLength = 0) {\n    if ( maxLength ) {\n      doc = Array.from(doc).slice(0, maxLength).join('');\n    }\n    Object.assign(fuzzy.options, HIGHLIGHT_OPTIONS_FUZZY);\n    const hl = fuzzy.highlight(doc); \n    DEBUG.verboseSlow && console.log(query, hl, maxLength);\n    return hl;\n  }\n\n  function beforePathChanged(new_path, {force: force = false} = {}) {\n    const currentBasePath = args.getBasePath();\n    if ( !force && (currentBasePath == new_path) ) {\n      return false;\n    }\n    saveFiles({useState:true, forceSave:true});\n    // clear all memory cache, index and full text indexes\n    State.Index.clear();\n    State.Cache.clear();\n    State.Docs.clear();\n    State.NDX_FTSIndex = NDX_FTSIndex = new NDXIndex(NDX_FIELDS);\n    State.Flex = Flex = new FTSIndex(FLEX_OPTS);\n    State.fuzzy = fuzzy = new Fuzzy({source: [...State.Docs.values()], keys: FUZZ_OPTS.keys});\n    return true;\n  }\n\n  async function afterPathChanged() { \n    DEBUG.verboseSlow && console.log({libraryPathChange:args.library_path()});\n    saveFiles({useState:true, forceSave:true});\n    // reloads from new path and updates Saved FilePaths\n    await loadFiles();\n  }\n\n  function saveCache(path) {\n    //DEBUG.verboseSlow && console.log(\"Writing to\", path || CACHE_FILE());\n    if ( State.Cache.size === 0 ) return;\n    Fs.writeFileSync(path || CACHE_FILE(), JSON.stringify([...State.Cache.entries()],null,2));\n  }\n\n  function saveIndex(path) {\n    if ( State.saveInProgress || Mode == 'serve' ) return;\n    if ( State.Index.size === 0 ) return;\n    State.saveInProgress = true;\n\n    clearTimeout(State.indexSaver);\n\n    DEBUG.verboseSlow && console.log(\n      `INDEXLOG: Writing Index (size: ${State.Index.size}) to`, path || INDEX_FILE()\n    );\n    //DEBUG.verboseSlow && console.log([...State.Index.entries()].sort(SORT_URLS));\n    Fs.writeFileSync(\n      path || INDEX_FILE(), \n      JSON.stringify([...State.Index.entries()].sort(SORT_URLS),null,2)\n    );\n\n    State.indexSaver = setTimeout(saveIndex, 11001);\n\n    State.saveInProgress = false;\n  }\n\n  function getIndex() {\n    const idx = JSON.parse(Fs.readFileSync(INDEX_FILE()))\n      .filter(([key]) => typeof key === 'string' && !hiddenKey(key))\n      .sort(([,{date:a}], [,{date:b}]) => b-a);\n    DEBUG.verboseSlow && console.log(idx);\n    return idx;\n  }\n\n  async function deleteFromIndexAndSearch(url) {\n    if ( State.Index.has(url) ) {\n      const {id, ndx_id, title, /*date,*/} = State.Index.get(url);\n      // delete index entries\n      State.Index.delete(url); \n      State.Index.delete(id);\n      State.Index.delete('ndx'+ndx_id);\n      // delete FTS entries (where we can)\n      State.NDX_FTSIndex.remove(ndx_id);\n      State.Flex.remove(id);\n      State.Docs.delete(url);\n      // save it all (to ensure we don't load data from disk that contains delete entries)\n      saveFiles({forceSave:true});\n      // and just rebuild the whole FTS index (where we must)\n      await loadFuzzy({fromMemOnly:true});\n      return {title};\n    }\n  }\n\n  async function search(query) {\n    const flex = (await Flex.searchAsync(query, args.results_per_page))\n      .map(id=> ({id, url: State.Index.get(id)}));\n    const ndx = NDX_FTSIndex.search(query)\n      .map(r => ({\n        ndx_id: r.key, \n        url: State.Index.get('ndx'+r.key), \n        score: r.score\n      }));\n    Object.assign(fuzzy.options, REGULAR_SEARCH_OPTIONS_FUZZY);\n    const fuzzRaw = fuzzy.search(query);\n    const fuzz = processFuzzResults(fuzzRaw);\n\n    const results = combineResults({flex, ndx, fuzz});\n    //console.log({flex,ndx,fuzz});\n    const ids = new Set(results);\n\n    const HL = new Map();\n    const highlights = fuzzRaw.filter(({id}) => ids.has(id)).map(obj => {\n      const title = State.Index.get(obj.url)?.title;\n      return {\n        id: obj.id,\n        url: Archivist.findOffsets(query, obj.url, MAX_URL_LENGTH) || obj.url,\n        title: Archivist.findOffsets(query, title, MAX_TITLE_LENGTH) || title,\n      };\n    });\n    highlights.forEach(hl => HL.set(hl.id, hl));\n\n    return {query,results, HL};\n  }\n\n  function combineResults({flex,ndx,fuzz}) {\n    DEBUG.verboseSlow && console.log({flex,ndx,fuzz});\n    const score = {};\n    flex.forEach(countRank(score));\n    ndx.forEach(countRank(score));\n    fuzz.forEach(countRank(score));\n    DEBUG.verboseSlow && console.log(score);\n  \n    const results = [...Object.values(score)].map(obj => {\n      try {\n        const {id} = State.Index.get(obj.url); \n        obj.id = id;\n        return obj;\n      } catch(e) {\n        DEBUG.verboseSlow && console.log({obj, index:State.Index, e, ndx, flex, fuzz});\n        console.error(\"Error\", e);\n        return obj;\n      }\n    });\n    results.sort(({score:scoreA}, {score:scoreB}) => scoreB-scoreA);\n    DEBUG.verboseSlow && console.log(results);\n    const resultIds = results.map(({id}) => id).filter(v => !!v);\n    return resultIds;\n  }\n\n  function countRank(record, weight = 1.0) {\n    return ({url, score:res_score = 1.0}, rank, all) => {\n      let result = record[url];\n      if ( ! result ) {\n        result = record[url] = {\n          url,\n          score: 0\n        };\n      }\n\n      result.score += res_score*weight*(all.length - rank)/all.length\n    };\n  }\n\n  function processFuzzResults(docs) {\n    const docIds = docs.map(({id}) => id); \n    const uniqueIds = new Set(docIds);\n    return [...uniqueIds.keys()].map(id => ({id, url:State.Index.get(id)}));\n  }\n\n  async function saveFTS(path = undefined, {forceSave:forceSave = false} = {}) {\n    if ( State.ftsSaveInProgress || Mode == 'serve' ) return;\n    State.ftsSaveInProgress = true;\n\n    clearTimeout(State.ftsIndexSaver);\n\n    DEBUG.verboseSlow && console.log(\"Writing FTS index to\", path || FTS_INDEX_DIR());\n    const dir = path || FTS_INDEX_DIR();\n\n    if ( forceSave || UpdatedKeys.size ) {\n      DEBUG.verboseSlow && console.log(`${UpdatedKeys.size} keys updated since last write`);\n      const flexBase = getFlexBase(dir);\n      Flex.export((key, data) => {\n        key = key.split('.').pop();\n        try {\n          Fs.writeFileSync(\n            Path.resolve(flexBase, key),\n            JSON.stringify(data, null, 2)\n          );\n        } catch(e) {\n          console.error('Error writing full text search index', e);\n        }\n      });\n      DEBUG.verboseSlow && console.log(`Wrote Flex to ${flexBase}`);\n      NDX_FTSIndex.save(dir);\n      saveFuzzy(dir);\n      UpdatedKeys.clear();\n    } else {\n      DEBUG.verboseSlow && console.log(\"No FTS keys updated, no writes needed this time.\");\n    }\n\n    State.ftsIndexSaver = setTimeout(saveFTS, 31001);\n    State.ftsSaveInProgress = false;\n  }\n\n  function shutdown(then) {\n    DEBUG.verboseSlow && console.log(`Archivist shutting down...`);  \n    saveFiles({forceSave:true});\n    Close && Close();\n    DEBUG.verboseSlow && console.log(`Archivist shut down.`);\n    return then && then();\n  }\n\n  function b64(s) {\n    return Buffer.from(s).toString('base64');\n  }\n\n  function NDXIndex(fields) {\n    let retVal;\n\n    // source: \n      // adapted from:\n      // https://github.com/ndx-search/docs/blob/94530cbff6ae8ea66c54bba4c97bdd972518b8b4/README.md#creating-a-simple-indexer-with-a-search-function\n\n    if ( ! new.target ) { throw `NDXIndex must be called with 'new'`; }\n\n    // `createIndex()` creates an index data structure.\n    // First argument specifies how many different fields we want to index.\n    const index = NDX(fields.length);\n    // `fieldAccessors` is an array with functions that used to retrieve data from different fields. \n    const fieldAccessors = fields.map(f => doc => doc[f.name]);\n    const fieldBoostFactors = fields.map(f => f.boost);\n    \n    retVal = {\n      index,\n      // `add()` function will add documents to the index.\n      add: doc => ndx(\n        retVal.index,\n        fieldAccessors,\n        // Tokenizer is a function that breaks text into words, phrases, symbols, or other meaningful elements\n        // called tokens.\n        // Lodash function `words()` splits string into an array of its words, see https://lodash.com/docs/#words for\n        // details.\n        words,\n        // Filter is a function that processes tokens and returns terms, terms are used in Inverted Index to\n        // index documents.\n        termFilter,\n        // Document key, it can be a unique document id or a refernce to a document if you want to store all documents\n        // in memory.\n        doc.ndx_id,\n        // Document.\n        doc,\n      ),\n      remove: id => {\n        removeDocumentFromIndex(retVal.index, NDXRemoved, id);\n        maybeClean();\n      },\n      update: (doc, old_id) => {\n        retVal.remove(old_id);\n        retVal.add(doc);\n      },\n      // `search()` function will be used to perform queries.\n      search: q => NDXQuery(\n        retVal.index,\n        fieldBoostFactors,\n        // BM25 ranking function constants:\n        1.2,  // BM25 k1 constant, controls non-linear term frequency normalization (saturation).\n        0.75, // BM25 b constant, controls to what degree document length normalizes tf values.\n        words,\n        termFilter,\n        // Set of removed documents, in this example we don't want to support removing documents from the index,\n        // so we can ignore it by specifying this set as `undefined` value.\n        NDXRemoved, \n        q,\n      ),\n      save: (basePath) => {\n        maybeClean(true);\n        const obj = toSerializable(retVal.index);\n        const objStr = JSON.stringify(obj, null, 2);\n        const path = getNDXPath(basePath);\n        Fs.writeFileSync(\n          path,\n          objStr\n        );\n        DEBUG.verboseSlow && console.log(\"Write NDX to \", path);\n      },\n      load: newIndex => {\n        retVal.index = newIndex;\n      }\n    };\n\n    DEBUG.verboseSlow && console.log('ndx setup', {retVal});\n    return retVal;\n\n    function maybeClean(doIt = false) {\n      if ( (doIt && NDXRemoved.size) || NDXRemoved.size >= REMOVED_CAP_TO_VACUUM_NDX ) {\n        vacuumIndex(retVal.index, NDXRemoved);\n      }\n    }\n  }\n\n  function loadNDXIndex(ndxFTSIndex) {\n    if ( Fs.existsSync(getNDXPath()) ) {\n      const indexContent = Fs.readFileSync(getNDXPath()).toString();\n      const index = fromSerializable(JSON.parse(indexContent));\n      ndxFTSIndex.load(index);\n    }\n    DEBUG.verboseSlow && console.log('NDX loaded');\n  }\n\n  function toNDXDoc({id, url, title, pageText}) {\n    // use existing defined id or a new one\n    return {\n      id, \n      ndx_id: NDXId++,\n      url,\n      i_url: getURI(url),\n      title, \n      content: pageText\n    };\n  }\n\n  function ndxDocFields({namesOnly:namesOnly = false} = {}) {\n    if ( !namesOnly && !NDX_OLD ) {\n      /* old format (for newer ndx >= v1 ) */\n      return [\n        /* we index over the special indexable url field, not the regular url field */\n        { name: \"title\", boost: 1.3 },\n        { name: \"i_url\", boost: 1.15 }, \n        { name: \"content\", boost: 1.0 },\n      ];\n    } else {\n      /* new format (for older ndx ~ v0.4 ) */\n      return [\n        \"title\",\n        \"i_url\",\n        \"content\"\n      ];\n    }\n  }\n\n  async function untilHas(thing, key, {timeout: timeout = false} = {}) {\n    if ( thing instanceof Map ) {\n      if ( thing.has(key) ) {\n        return thing.get(key);\n      } else {\n        let resolve;\n        const pr = new Promise(res => resolve = res);\n        const then = Date.now();\n        const checker = setInterval(() => {\n          const now = Date.now();\n          if ( thing.has(key) || (timeout && (now-then) >= timeout) ) {\n            clearInterval(checker);\n            resolve(thing.get(key));\n          } else {\n            DEBUG.verboseSlow && console.log(thing, \"not have\", key);\n          }\n        }, CHECK_INTERVAL);\n\n        return pr;\n      }\n    } else if ( thing instanceof Set ) {\n      if ( thing.has(key) ) {\n        return true;\n      } else {\n        let resolve;\n        const pr = new Promise(res => resolve = res);\n        const then = Date.now();\n        const checker = setInterval(() => {\n          const now = Date.now();\n          if ( thing.has(key) || (timeout && (now-then) >= timeout) ) {\n            clearInterval(checker);\n            resolve(true);\n          } else {\n            DEBUG.verboseSlow && console.log(thing, \"not have\", key);\n          }\n        }, CHECK_INTERVAL);\n\n        return pr;\n      }\n    } else if ( typeof thing === \"object\" ) {\n      if ( thing[key] ) {\n        return true;\n      } else {\n        let resolve;\n        const pr = new Promise(res => resolve = res);\n        const then = Date.now();\n        const checker = setInterval(() => {\n          const now = Date.now();\n          if ( thing[key] || (timeout && (now-then) >= timeout) ) {\n            clearInterval(checker);\n            resolve(true);\n          } else {\n            DEBUG.verboseSlow && console.log(thing, \"not have\", key);\n          }\n        }, CHECK_INTERVAL);\n\n        return pr;\n      }\n    } else {\n      throw new TypeError(`untilHas with thing of type ${thing} is not yet implemented!`);\n    }\n  }\n\n  function getNDXPath(basePath) {\n    return Path.resolve(args.ndx_fts_index_dir(basePath), 'index.ndx');\n  }\n\n  function getFuzzyPath(basePath) {\n    return Path.resolve(args.fuzzy_fts_index_dir(basePath), 'docs.fzz');\n  }\n\n  function getFlexBase(basePath) {\n    return args.flex_fts_index_dir(basePath);\n  }\n\n  function addFrameNode(observedFrame) {\n    const {frameId, parentFrameId} = observedFrame;\n    const node = {\n      id: frameId,\n      parentId: parentFrameId,\n      parent: State.FrameNodes.get(parentFrameId)\n    };\n\n    DEBUG.verboseSlow && console.log({observedFrame});\n\n    State.FrameNodes.set(node.id, node);\n\n    return node;\n  }\n\n  function updateFrameNode(frameNavigated) {\n    const {\n      frame: {\n        id: frameId, \n        parentId, url: rawUrl, urlFragment, \n        /*\n        domainAndRegistry, unreachableUrl, \n        adFrameStatus\n        */\n      }\n    } = frameNavigated;\n    const url = urlFragment?.startsWith(rawUrl.slice(0,4)) ? urlFragment : rawUrl;\n    let frameNode = State.FrameNodes.get(frameId);\n\n    DEBUG.verboseSlow && console.log({frameNavigated});\n\n    if ( ! frameNode ) {\n      // Note\n        // This is not actually a panic because\n        // it can happen. It may just mean \n        // this isn't a sub frame.\n        // So rather than panicing:\n          /*\n          throw new TypeError(\n            `Sanity check failed: frameId ${\n              frameId\n            } is not in our FrameNodes data, which currently has ${\n              State.FrameNodes.size\n            } entries.`\n          );\n          */\n        // We do this instead (just add it):\n      frameNode = addFrameNode({frameId, parentFrameId: parentId});\n    }\n\n    if ( frameNode.id !== frameId ) {\n      throw new TypeError(\n        `Sanity check failed: Child frameId ${\n          frameNode.frameId\n        } was supposed to be ${\n          frameId\n        }`\n      );\n    }\n\n    // Note:\n      // use the urlFragment (a URL + the hash fragment identifier) \n      // only if it's actually a URL\n\n    // Update frame node url (and possible parent)\n      frameNode.url = url;\n      if ( parentId !== frameNode.parentId ) {\n        console.info(`Interesting. Frame parent changed from ${frameNode.parentId} to ${parentId}`);\n        frameNode.parentId = parentId;\n        frameNode.parent = State.FrameNodes.get(parentId);\n        if ( parentId && !frameNode.parent ) {\n          throw new TypeError(\n            `!! FrameNode ${\n              frameId\n            } uses parentId ${\n              parentId\n            } but we don't have any record of ${\n              parentId\n            } in out FrameNodes data`\n          );\n        }\n      }\n\n    // comment out these details but reserve for possible future use\n      /*\n      frameNode.detail = {\n        unreachableUrl, urlFragment,  \n        domainAndRegistry, adFrameStatus\n      };\n      */\n  }\n\n  /*\n  function removeFrameNode(frameDetached) {\n    const {frameId, reason} = frameDetached;\n    throw new TypeError(`removeFrameNode is not implemented`);\n  }\n  */\n\n  function getRootFrameURL(frameId) {\n    let frameNode = State.FrameNodes.get(frameId);\n    if ( ! frameNode ) {\n      DEBUG.verboseSlow && console.warn(new TypeError(\n        `Sanity check failed: frameId ${\n          frameId\n        } is not in our FrameNodes data, which currently has ${\n          State.FrameNodes.size\n        } entries.`\n      ));\n      return;\n    }\n    if ( frameNode.id !== frameId ) {\n      throw new TypeError(\n        `Sanity check failed: Child frameId ${\n          frameNode.id\n        } was supposed to be ${\n          frameId\n        }`\n      );\n    }\n    while(frameNode.parent) {\n      frameNode = frameNode.parent;\n    }\n    return frameNode.url;\n  }\n\n// crawling\n  async function archiveAndIndexURL(url, {\n      crawl, \n      createIfMissing:createIfMissing = false, \n      timeout, \n      depth, \n      TargetId,\n      program,\n    } = {}) {\n      DEBUG.verboseSlow && console.log('ArchiveAndIndex', url, {crawl, createIfMissing, timeout, depth, TargetId, program});\n      if ( Mode == 'serve' ) {\n        throw new TypeError(`archiveAndIndexURL can not be used in 'serve' mode.`);\n      }\n      if ( program ) {\n        State.program = program;\n      }\n      let targetId = TargetId;\n      let sessionId;\n      if ( ! dontCache({url}) ) {\n        const {send, on, close} = State.connection;\n        const {targetInfos:targs} = await send(\"Target.getTargets\", {});\n        const targets = targs.reduce((M,T) => {\n          M.set(T.url, T);\n          M.set(T.targetId, T);\n          return M;\n        }, new Map);\n        DEBUG.verboseSlow && console.log('Targets', targets);\n        if ( targets.has(url) || targets.has(targetId) ) {\n          DEBUG.verboseSlow && console.log('We have target', url, targetId);\n          const targetInfo = targets.get(url) || targets.get(targetId);\n          ({targetId} = targetInfo);\n          if ( crawl && ! State.CrawlData.has(targetId) ) {\n            State.CrawlIndexing.add(targetId)\n            State.CrawlData.set(targetId, {depth, links:[]});\n            if ( State.visited.has(url) ) {\n              return [];\n            } else {\n              State.visited.add(url);\n            }\n          }\n          sessionId = State.Sessions.get(targetId);\n          DEBUG.verboseSlow && console.log(\n            \"Reloading to archive and index in select (Bookmark) mode\", \n            url\n          );\n          if ( State.program && ! dontCache(targetInfo) ) {\n            const fs = Fs;\n            const path = Path;\n            try {\n              await sleep(500);\n              await eval(`(async () => {\n                try {\n                  ${State.program}\n                } catch(e) {\n                  console.warn('Error in program', e, State.program);\n                }\n              })();`);\n              await sleep(500);\n            } catch(e) {\n              console.warn(`Error evaluate program`, e);\n            }\n          }\n\n          await untilTrue(async () => {\n            const {result:{value:loaded}} = await send(\"Runtime.evaluate\", {\n              expression: `(function () {\n                return document.readyState === 'complete'; \n              }())`,\n              returnByValue: true\n            }, sessionId);\n            DEBUG.verboseSlow && console.log({loaded, targetInfo});\n            return loaded;\n          });\n          //send(\"Page.stopLoading\", {}, sessionId);\n          send(\"Page.reload\", {}, sessionId);\n          if ( crawl ) {\n            let resolve;\n            const pageLoaded = new Promise(res => resolve = res).then(() => sleep(1000));\n            {\n              on(\"Page.loadEventFired\", resolve);\n              //console.log(targets, targetId, targets.get(targetId));\n              const {result:{value:loaded}} = await send(\"Runtime.evaluate\", {\n                expression: `(function () {\n                  return document.readyState === 'complete'; \n                }())`,\n                returnByValue: true\n              }, sessionId);\n              if ( loaded ) {\n                resolve(true);\n              }\n            }\n            let notifyStable;\n            const pageHTMLStabilized = new Promise(res => notifyStable = res);\n            setTimeout(async () => {\n              const timeout = MAX_TIME_PER_PAGE / 4;\n              const checkDurationMsecs = 1618;\n              const maxChecks = timeout / checkDurationMsecs;\n              let lastSize = 0;\n              let checkCounts = 1;\n              let countStableSizeIterations = 0;\n              const minStableSizeIterations = 3;\n\n              while(checkCounts++ <= maxChecks) {\n                const flatDoc = await send(\"DOMSnapshot.captureSnapshot\", {\n                  computedStyles: [],\n                }, sessionId);\n                const pageText = processDoc(flatDoc).replace(STRIP_CHARS, ' ');\n                const currentSize = pageText.length;\n\n                if(lastSize != 0 && currentSize == lastSize) \n                  countStableSizeIterations++;\n                else \n                  countStableSizeIterations = 0; //reset the counter\n\n                if(countStableSizeIterations >= minStableSizeIterations) {\n                  notifyStable(true);\n                }\n\n                lastSize = currentSize;\n                await sleep(checkDurationMsecs);\n              }\n\n              notifyStable(false);\n            }, 0);\n\n            await pageLoaded;\n            \n            if ( State.program && ! dontCache(targetInfo) ) {\n              const fs = Fs;\n              const path = Path;\n              try {\n                await sleep(500);\n                await eval(`(async () => {\n                  try {\n                    ${State.program}\n                  } catch(e) {\n                    console.warn('Error in program', e, State.program);\n                  }\n                })();`);\n                await sleep(500);\n              } catch(e) {\n                console.warn(`Error evaluate program`, e);\n              }\n            }\n\n            await Promise.race([\n              await Promise.all([\n                pageHTMLStabilized,\n                untilTrue(() => !State.CrawlIndexing.has(targetId), timeout/5, timeout),\n                sleep(State.minPageCrawlTime || MIN_TIME_PER_PAGE)\n              ]),\n              sleep(State.maxPageCrawlTime || MAX_TIME_PER_PAGE)\n            ]);\n\n            console.log(`Closing page ${url}, at target ${targetId}`);\n\n            await send(\"Target.closeTarget\", {targetId});\n            State.CrawlTargets.delete(targetId);\n          }\n        } else if ( createIfMissing ) {\n          DEBUG.verboseSlow && console.log('We create target', url);\n          try {\n            targetId = null;\n            ({targetId} = await send(\"Target.createTarget\", {\n              url: `${GO_SECURE ? 'https://localhost' : 'http://127.0.0.1'}:${args.server_port}/redirector.html?url=${\n                encodeURIComponent(url)\n              }`\n            }));\n          } catch(e) {\n            console.warn(\"Error creating new tab for url\", url, e);\n            return;\n          }\n          if ( crawl && ! State.CrawlData.has(targetId) ) {\n            State.CrawlTargets.add(targetId);\n            State.CrawlIndexing.add(targetId);\n            State.CrawlData.set(targetId, {depth, links:[]});\n          }\n          return archiveAndIndexURL(url, {\n            crawl, timeout, depth, createIfMissing: false, /* prevent redirect loops */\n            TargetId: targetId,\n            program,\n          });\n        }\n      } else {\n        DEBUG.verboseSlow && console.warn(\n          `archiveAndIndexURL called in mode ${\n            Mode\n           } for URL ${\n            url\n           } but that URL is not in our Bookmarks list.`\n        );\n      }\n      if ( crawl && State.CrawlData.has(targetId) ) {\n        const {links} = State.CrawlData.get(targetId);\n        console.log({targetId,links});\n        State.CrawlData.delete(targetId);\n        return links;\n      } else {\n        return [];\n      }\n  }\n\n  export async function startCrawl({\n    urls, timeout, depth, saveToFile: saveToFile = false,\n    batchSize,\n    minPageCrawlTime, \n    maxPageCrawlTime,\n    program,\n  } = {}) {\n    if ( State.crawling ) {\n      console.log('Already crawling...');\n      return;\n    }\n    if ( saveToFile ) {\n      logName = `crawl-${(new Date).toISOString()}.urls.txt`; \n      logStream = Fs.createWriteStream(Path.resolve(args.CONFIG_DIR, logName), {flags:'as+'});\n    }\n    console.log('StartCrawl', {urls, timeout, depth, batchSize, saveToFile, minPageCrawlTime, maxPageCrawlTime, program});\n    State.crawling = true;\n    State.crawlDepth = depth;\n    State.crawlTimeout = timeout;\n    State.visited = new Set();\n    Object.assign(State,{\n      batchSize,\n      minPageCrawlTime,\n      maxPageCrawlTime\n    });\n    const batch_sz = State.batchSize || BATCH_SIZE;\n    let totalBytes = 0;\n    setTimeout(async () => {\n      try {\n        while(urls.length >= batch_sz) {\n          const jobs = [];\n          const batch = urls.splice(urls.length-batch_sz,batch_sz);\n          console.log({urls, batch});\n          for( let i = 0; i < batch_sz; i++ ) {\n            const {depth,url} = batch.shift();\n            const pr = archiveAndIndexURL(\n              url, \n              {crawl: true, depth, timeout, createIfMissing:true, getLinks: depth >= 1, program}\n            );\n            jobs.push(pr);\n          }\n          const links = (await Promise.all(jobs)).flat().filter(({url}) => !Q.has(url));\n          if ( links.length ) {\n            urls.push(...links);\n            links.forEach(({url}) => Q.add(url)); \n          }\n        }\n        while(urls.length) {\n          const {depth,url} = urls.pop();\n          const links = (await archiveAndIndexURL(\n            url, \n            {crawl: true, depth, timeout, createIfMissing:true, getLinks: depth >= 1, program}\n          )).filter(({url}) => !Q.has(url));\n          console.log(links, Q);\n          if ( links.length ) {\n            urls.push(...links);\n            links.forEach(({url}) => Q.add(url)); \n          }\n        }\n      } catch(e) {\n        console.warn(e);\n        throw new RichError({status:500, message: e.message});\n      } finally {\n        await untilTrue(() => State.CrawlData.size === 0 && State.CrawlTargets.size === 0, -1)\n        State.crawling = false;\n        State.crawlDepth = false;\n        State.crawlTimeout = false;\n        State.visited = false;\n        if ( saveToFile ) {\n          logStream.close();\n          totalBytes = logStream.bytesWritten;\n          console.log(`Wrote ${totalBytes} bytes of URLs to ${logName}`);\n        }\n        console.log(`Crawl finished.`);\n      }\n    }, 0);\n  }\n"
  },
  {
    "path": "src/args.js",
    "content": "import os from 'os';\nimport path from 'path';\nimport fs from 'fs';\n\nconst server_port = process.env.PORT || process.argv[2] || 22120;\nconst mode = process.argv[3] || 'save';\nconst chrome_port = process.argv[4] || 9222;\n\nconst Pref = {};\nexport const CONFIG_DIR = path.resolve(os.homedir(), '.config', 'dosyago', 'DownloadNet');\nfs.mkdirSync(CONFIG_DIR, {recursive:true});\nconst pref_file = path.resolve(CONFIG_DIR, 'config.json');\nconst cacheId = Math.random();\n\nloadPref();\n\nlet BasePath = Pref.BasePath;\nexport const archive_root = () => path.resolve(BasePath, '22120-arc');\nexport const no_file = () => path.resolve(archive_root(), 'no.json');\nexport const temp_browser_cache = () => path.resolve(archive_root(), 'temp-browser-cache' + cacheId);\nexport const library_path = () => path.resolve(archive_root(), 'public', 'library');\nexport const cache_file = () => path.resolve(library_path(), 'cache.json');\nexport const index_file = () => path.resolve(library_path(), 'index.json');\nexport const fts_index_dir = () => path.resolve(library_path(), 'fts');\n\nconst flex_fts_index_dir = base => path.resolve(base || fts_index_dir(), 'flex');\nconst ndx_fts_index_dir = base => path.resolve(base || fts_index_dir(), 'ndx');\nconst fuzzy_fts_index_dir = base => path.resolve(base || fts_index_dir(), 'fuzzy');\n\nconst results_per_page = 10;\n\nupdateBasePath(process.argv[5] || Pref.BasePath || CONFIG_DIR);\n\nconst args = {\n  mode,\n\n  server_port, \n  chrome_port,\n\n  updateBasePath,\n  getBasePath,\n\n  library_path,\n  no_file,\n  temp_browser_cache,\n  cache_file,\n  index_file,\n  fts_index_dir,\n  flex_fts_index_dir,\n  ndx_fts_index_dir,\n  fuzzy_fts_index_dir,\n\n  results_per_page,\n  CONFIG_DIR\n};\n\nexport default args;\n\nfunction updateBasePath(new_base_path, {force:force = false, before: before = []} = {}) {\n  new_base_path = path.resolve(new_base_path);\n  if ( !force && (BasePath == new_base_path) ) {\n    return false;\n  }\n\n  console.log(`Updating base path from ${BasePath} to ${new_base_path}...`);\n  BasePath = new_base_path;\n\n  if ( Array.isArray(before) ) {\n    for( const task of before ) {\n      try { task(); } catch(e) { \n        console.error(`before updateBasePath task failed. Task: ${task}`);\n      }\n    }\n  } else {\n    throw new TypeError(`If given, argument before to updateBasePath() must be an array of functions.`);\n  }\n\n  if ( !fs.existsSync(library_path()) ) {\n    console.log(`Archive directory (${library_path()}) does not exist, creating...`);\n    fs.mkdirSync(library_path(), {recursive:true});\n    console.log(`Created.`);\n  }\n\n  if ( !fs.existsSync(cache_file()) ) {\n    console.log(`Cache file does not exist, creating...`); \n    fs.writeFileSync(cache_file(), JSON.stringify([]));\n    console.log(`Created!`);\n  }\n\n  if ( !fs.existsSync(index_file()) ) {\n    //console.log(`INDEXLOG: Index file does not exist, creating...`); \n    fs.writeFileSync(index_file(), JSON.stringify([]));\n    console.log(`Created!`);\n  }\n\n  if ( !fs.existsSync(flex_fts_index_dir()) ) {\n    console.log(`FTS Index directory does not exist, creating...`); \n    fs.mkdirSync(flex_fts_index_dir(), {recursive:true});\n    console.log(`Created!`);\n  }\n\n  if ( !fs.existsSync(ndx_fts_index_dir()) ) {\n    console.log(`NDX FTS Index directory does not exist, creating...`); \n    fs.mkdirSync(ndx_fts_index_dir(), {recursive:true});\n    console.log(`Created!`);\n  }\n\n  if ( !fs.existsSync(fuzzy_fts_index_dir()) ) {\n    console.log(`FUZZY FTS Index directory does not exist, creating...`); \n    fs.mkdirSync(fuzzy_fts_index_dir(), {recursive:true});\n    fs.writeFileSync(path.resolve(fuzzy_fts_index_dir(), 'docs.fzz'), JSON.stringify([]));\n    console.log('Also creating FUZZY FTS Index docs file...');\n    console.log(`Created all!`);\n  }\n\n\n\n  console.log(`Base path updated to: ${BasePath}. Saving to preferences...`);\n  Pref.BasePath = BasePath;\n  savePref();\n  console.log(`Saved!`);\n\n  return true;\n}\n\nfunction getBasePath() {\n  return BasePath;\n}\n\nexport function loadPref() {\n  if ( fs.existsSync(pref_file) ) {\n    try {\n      Object.assign(Pref, JSON.parse(fs.readFileSync(pref_file)));\n    } catch(e) {\n      console.warn(\"Error reading from preferences file\", e);\n    }\n  } else {\n    console.log(\"Preferences file does not exist. Creating one...\"); \n    savePref();\n  }\n  return clone(Pref);\n}\n\nfunction savePref() {\n  try {\n    fs.writeFileSync(pref_file, JSON.stringify(Pref,null,2));\n  } catch(e) {\n    console.warn(\"Error writing preferences file\", pref_file, Pref, e);\n  }\n}\n\nfunction clone(o) {\n  return JSON.parse(JSON.stringify(o));\n}\n\n"
  },
  {
    "path": "src/blockedResponse.js",
    "content": "export const BLOCKED_CODE = 200;\nexport const BLOCKED_BODY = Buffer.from(`\n  <style>:root { font-family: system-ui, monospace; }</style>\n  <h1>Request blocked</h1>\n  <p>This navigation was prevented by 22120 as a Chrome bug fix for some requests causing issues.</p>\n`).toString(\"base64\");\nexport const BLOCKED_HEADERS = [\n  {name: \"X-Powered-By\", value: \"Dosyago-Corporation\"},\n  {name: \"X-Blocked-Internally\", value: \"Custom 22120 Chrome bug fix\"},\n  {name: \"Accept-Ranges\", value: \"bytes\"},\n  {name: \"Cache-Control\", value: \"public, max-age=0\"},\n  {name: \"Content-Type\", value: \"text/html; charset=UTF-8\"},\n  {name: \"Content-Length\", value: `${BLOCKED_BODY.length}`}\n];\n\nconst BLOCKED_RESPONSE = `\nHTTP/1.1 ${BLOCKED_CODE} OK\nX-Powered-By: Zanj-Dosyago-Corporation\nX-Blocked-Internally: Custom ad blocking\nAccept-Ranges: bytes\nCache-Control: public, max-age=0\nContent-Type: text/html; charset=UTF-8\nContent-Length: ${BLOCKED_BODY.length}\n\n${BLOCKED_BODY}\n`;\n\nexport default BLOCKED_RESPONSE;\n\n"
  },
  {
    "path": "src/bookmarker.js",
    "content": "import os from 'os';\nimport Path from 'path';\nimport fs from 'fs';\n\nimport {DEBUG as debug} from './common.js';\n\nconst DEBUG = debug || false;\n// Chrome user data directories by platform. \n  // Source 1: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md \n  // Source 2: https://superuser.com/questions/329112/where-are-the-user-profile-directories-of-google-chrome-located-in\n\nconst FS_WATCH_OPTS = {\n  persistent: false,\n};\n\n// Note:\n  // Not all the below are now used or supported by this code\nconst UDD_PATHS = {\n  'win': '%LOCALAPPDATA%\\\\Google\\\\Chrome\\\\User Data',\n  'winxp' : '%USERPROFILE%\\\\Local Settings\\\\Application Data\\\\Google\\\\Chrome\\\\User Data',\n  'macos' : Path.resolve(os.homedir(), 'Library/Application Support/Google/Chrome'),\n  'nix' : Path.resolve(os.homedir(), '.config/google-chrome'),\n  'chromeos': '/home/chronos',                        /* no support */\n  'ios': 'Library/Application Support/Google/Chrome', /* no support */\n};\nconst PLAT_TABLE = {\n  'darwin': 'macos',\n  'linux': 'nix'\n};\nconst PROFILE_DIR_NAME_REGEX = /^(Default|Profile \\d+)$/i;\nconst isProfileDir = name => PROFILE_DIR_NAME_REGEX.test(name);\nconst BOOKMARK_FILE_NAME_REGEX = /^Bookmarks$/i;\nconst isBookmarkFile = name => BOOKMARK_FILE_NAME_REGEX.test(name);\nconst State = {\n  active: new Set(), /* active Bookmark files (we don't know these until file changes) */\n  books: {\n\n  }\n};\n\nexport async function* bookmarkChanges() {\n  // try to get the profile directory\n    const rootDir = getProfileRootDir();\n\n    if ( !fs.existsSync(rootDir) ) {\n      throw new TypeError(`Sorry! The directory where we thought the Chrome profile directories may be found (${rootDir}), does not exist. We can't monitor changes to your bookmarks, so Bookmark Select Mode is not supported.`);\n    }\n\n  // state constants and variables (including chokidar file glob observer)\n    const observers = [];\n    const ps = [];\n    let change = false;\n    let notifyChange = false;\n    let stopLooping = false;\n    let shuttingDown = false;\n\n  // create sufficient observers\n    const dirs = fs.readdirSync(rootDir, {withFileTypes:true}).reduce((Files, dirent) => {\n      if ( dirent.isDirectory() && isProfileDir(dirent.name) ) {\n        const filePath = Path.resolve(rootDir, dirent.name);\n\n        if ( fs.existsSync(filePath) ) {\n          Files.push(filePath); \n        }\n      }\n      return Files;\n    }, []);\n    for( const dirPath of dirs ) {\n      // first read it in\n        const filePath = Path.resolve(dirPath, 'Bookmarks');\n        if ( fs.existsSync(filePath) ) {\n          const data = fs.readFileSync(filePath);\n          const jData = JSON.parse(data);\n          State.books[filePath] = flatten(jData, {toMap:true});\n        }\n\n      const observer = fs.watch(dirPath, FS_WATCH_OPTS);\n      console.log(`Observing ${dirPath}`);\n      // Note\n        // allow the parent process to exit \n        //even if observer is still active somehow\n        observer.unref();\n\n      // listen for all events from the observer\n        observer.on('change', (event, filename) => {\n          filename = filename || '';\n          // listen to everything\n          const path = Path.resolve(dirPath, filename);\n          DEBUG.verboseSlow && console.log(event, path);\n          if ( isBookmarkFile(filename) ) {\n            if ( ! State.active.has(path) ) {\n              State.active.add(path);\n            }\n            // but only act if it is a bookmark file\n            DEBUG.verboseSlow && console.log(event, path, notifyChange);\n            // save the event type and file it happened to\n            change = {event, path};\n            // drop the most recently pushed promise from our bookkeeping list\n            ps.pop();\n            // resolve the promise in the wait loop to process the bookmark file and emit the changes\n            notifyChange && notifyChange();\n          }\n        });\n        observer.on('error', error => {\n          console.warn(`Bookmark file observer for ${dirPath} error`, error);\n          observers.slice(observers.indexOf(observer), 1);\n          if ( observers.length ) {\n            notifyChange && notifyChange();\n          } else {\n            stopLooping && stopLooping();\n          }\n        });\n        observer.on('close', () => {\n          console.info(`Observer for ${dirPath} closed`);\n          observers.slice(observers.indexOf(observer), 1);\n          if ( observers.length ) {\n            notifyChange && notifyChange();\n          } else {\n            stopLooping && stopLooping();\n          }\n        });\n\n      observers.push(observer);\n    }\n\n  // make sure we kill the watcher on process restart or shutdown\n    process.on('SIGTERM', shutdown);\n    process.on('SIGHUP', shutdown);\n    process.on('SIGINT',  shutdown);\n    process.on('SIGBRK', shutdown);\n\n  // the main wait loop that enables us to turn a traditional NodeJS eventemitter\n  // into an asychronous stream generator\n  waiting: while(true) {\n    // Note: code resilience\n      //the below two statements can come in any order in this loop, both work\n\n    // get, process and publish changes\n      // only do if the change is there (first time it won't be because\n      // we haven't yielded out (async or yield) yet)\n      if ( change ) {\n        const {path} = change;\n        change = false;\n\n        try {\n          const changes = flatten(\n            JSON.parse(fs.readFileSync(path)), \n            {toMap:true, map: State.books[path]}\n          );\n\n          for( const changeEvent of changes ) yield changeEvent;\n        } catch(e) {\n          console.warn(`Error publishing Bookmarks changes`, e);\n        }\n      }\n\n    // wait for the next change\n      // always wait tho (to allow queueing of the next event to process)\n      try {\n        await new Promise((res, rej) => {\n          // save these\n          notifyChange = res;   // so we can turn the next turn of the loop\n          stopLooping = rej;    // so we can break out of the loop (on shutdown)\n          ps.push({res,rej});   // so we can clean up any left over promises\n        });\n      } catch { \n        ps.pop();\n        break waiting; \n      }\n  }\n\n  shutdown();\n\n  return true;\n\n  async function shutdown() {\n    if ( shuttingDown ) return;\n    shuttingDown = true;\n    console.log('Bookmark observer shutting down...');\n    // clean up any outstanding waiting promises\n    while ( ps.length ) {\n      /* eslint-disable no-empty */\n      try { ps.pop().rej(); } finally {}\n      /* eslint-enable no-empty */\n    }\n    // stop the waiting loop\n    stopLooping && setTimeout(() => stopLooping('bookmark watching stopped'), 0);\n    // clean up any observers\n    while(observers.length) {\n      /* eslint-disable no-empty */\n      try { observers.pop().close(); } finally {}\n      /* eslint-enable no-empty */\n    }\n    console.log('Bookmark observer shut down cleanly.');\n  }\n}\n\nexport function hasBookmark(url) {\n  return Object.keys(State.books).filter(key => {\n    if ( State.active.size == 0 ) return true; \n    return State.active.has(key);\n  }).map(key => State.books[key])\n    .some(map => map.has(url));\n}\n\nfunction getProfileRootDir() {\n  const plat = os.platform();\n  let name = PLAT_TABLE[plat];\n  let rootDir;\n\n  DEBUG.verboseSlow && console.log({plat, name});\n\n  if ( !name ) {\n    if ( plat === 'win32' ) {\n      // because Chrome profile dir location only changes in XP\n        // we only care if it's XP or not and so\n        // we try to resolve based on the version major and minor (given by release)\n        // source: https://docs.microsoft.com/en-us/windows/win32/sysinfo/operating-system-version?redirectedfrom=MSDN\n      const rel = os.release();\n      const ver = parseFloat(rel); \n      if ( !Number.isNaN(ver) && ver <= 5.2 ) {\n        // this should be reliable\n        name = 'winxp';\n      } else {\n        // this may not be reliable, but we just do it\n        name = 'win';\n      }\n    } else {\n      throw new TypeError(\n        `Sorry! We don't know how to find the default Chrome profile on OS platform: ${plat}`\n      );\n    }\n  }\n\n  if ( UDD_PATHS[name] ) {\n    rootDir = Path.resolve(resolveEnvironmentVariablesToPathSegments(UDD_PATHS[name]));\n  } else {\n    throw new TypeError(\n      `Sorry! We don't know how to find the default Chrome profile on OS name: ${name}`\n    );\n  }\n\n  return rootDir;\n}\n\nfunction flatten(bookmarkObj, {toMap: toMap = false, map} = {}) {\n  const nodes = [...Object.values(bookmarkObj.roots)];\n  const urls = toMap? (map || new Map()) : [];\n  const urlSet = new Set();\n  const changes = [];\n\n  while(nodes.length) {\n    const next = nodes.pop();\n    const {name, type, url} = next;\n    switch(type) {\n      case \"url\":\n        if ( toMap ) {\n          if ( map ) {\n            if ( urls.has(url) ) {\n              const {name:oldName} = urls.get(url);\n              if ( name !== oldName ) {\n                if ( !urlSet.has(url) ) {\n                  changes.push({\n                    type: \"Title updated\",\n                    url,\n                    oldName, \n                    name\n                  });\n                }\n              }\n            } else {\n              changes.push({\n                type: \"new\",\n                name, url\n              });\n            }\n          } \n          if ( !urlSet.has(url) ) {\n            urls.set(url, next);\n          }\n          urlSet.add(url);\n        } else {\n          urls.push(next);\n        }\n        break;\n      case \"folder\":\n        nodes.push(...next.children);\n        break;\n      default:\n        console.info(\"New type\", type, next);\n        break;\n      \n    }\n  }\n\n  if (map) {\n    [...map.keys()].forEach(url => {\n      if ( !urlSet.has(url) ) {\n        changes.push({\n          type: \"delete\",\n          url\n        });\n        map.delete(url);\n      }\n    });\n  }\n\n  return map ? changes : urls;\n}\n\n// source: https://stackoverflow.com/a/33017068\nfunction resolveEnvironmentVariablesToPathSegments(path) {\n  return path.replace(/%([^%]+)%/g, function(_, key) {\n    return process.env[key];\n  });\n}\n\n/*\ntest();\nasync function test() {\n  for await ( const change of bookmarkChanges() ) {\n    console.log(change);\n  }\n}\n*/\n\n\n/*\nfunction* profileDirectoryEnumerator(maxN = 9999) {\n  let index = 0;  \n  while(index <= maxN) {\n    const profileDirName = index ? `Profile ${index}` : `Default`;\n    yield profileDirName;\n  }\n}\n*/\n"
  },
  {
    "path": "src/common.js",
    "content": "import path from 'path';\nimport {fileURLToPath} from 'url';\nimport fs from 'fs';\nimport os from 'os';\nimport { root } from './root.js';\n\nconst { APP_ROOT: __ROOT } = root;\n\nconst DEEB = process.env.DEBUG_22120_VERBOSE || false;\n\nexport const DEBUG = {\n  showBrowser: false,\n  verboseBrowser: false,\n  showList: false,\n  showStatus: false,\n  debugSec: false,\n  askFirst: true,\n  verboseSlow: process.env.VERBOSE_DEBUG_22120 || DEEB,\n  debug: process.env.DEBUG_22120 || DEEB,\n  verbose: DEEB || process.env.VERBOSE_DEBUG_22120 || process.env.DEBUG_22120,\n  checkPred: false,\n}\nexport const SHOW_FETCH = false;\n\nif ( DEBUG.debug ) {\n  console.log({__ROOT});\n}\n\n// server related\nexport const PUBLIC_SERVER = true;\n\n// crawl related\nexport const MIN_TIME_PER_PAGE = 10000;\nexport const MAX_TIME_PER_PAGE = 32000;\nexport const MIN_WAIT = 200;\nexport const MAX_WAITS = 300;\nexport const BATCH_SIZE = 5; // crawl batch size (how many concurrent tabs for crawling)\nexport const MAX_REAL_URL_LENGTH = 2**15 - 1;\n\nexport const CHECK_INTERVAL = 400;\nexport const TEXT_NODE = 3;\nexport const MAX_HIGHLIGHTABLE_LENGTH = 0;    /* 0 is no max length for highlight */\nexport const MAX_TITLE_LENGTH = 140;\nexport const MAX_URL_LENGTH = 140;\nexport const MAX_HEAD = 140;\n\nconst LOCALP = path.resolve(os.homedir(), 'local-sslcerts', 'privkey.pem');\nconst ANYP = path.resolve(os.homedir(), 'sslcerts', 'privkey.pem');\nexport const GO_SECURE = fs.existsSync(LOCALP) || fs.existsSync(ANYP);\nconst cert_path = GO_SECURE ? path.dirname(fs.existsSync(LOCALP) ? LOCALP : fs.existsSync(ANYP) ? ANYP : null) : null;\nexport const CERT_PATH = () => GO_SECURE ? cert_path : false;\n\nexport class RichError extends Error {\n  constructor(msg) {\n    super(msg);\n    let textMessage;\n    try {\n      textMessage = JSON.stringify(msg);\n    } catch(e) {\n      console.warn(`Could not create RichError from argument ${msg.toString ? msg.toString() : msg} as JSON serialization failed. RichError argument MUST be JSON serializable. Failure error was:`, e);\n      return;\n    }\n    super(textMessage);\n  }\n}\n\n/* text nodes inside these elements that are ignored */\nexport const FORBIDDEN_TEXT_PARENT = new Set([\n  'STYLE',\n  'SCRIPT',\n  'NOSCRIPT',\n  /* we could remove these last two so as to index them as well */\n  'DATALIST',\n  'OPTION'\n]);\nexport const ERROR_CODE_SAFE_TO_IGNORE = new Set([\n  -32000, /* message:\n            Can only get response body on requests captured after headers received.\n           * ignore because: \n              seems to only happen when new navigation aborts all \n              pending requests of the unloading page \n           */\n  -32602, /* message:\n            Invalid InterceptionId.\n           * ignore because: \n              seems to only happen when new navigation aborts all \n              pending requests of the unloading page \n           */\n]);\n\nexport const SNIP_CONTEXT = 31;\n\nexport const NO_SANDBOX = (process.env.DEBUG_22120 && process.env.SET_22120_NO_SANDBOX) || false;\n\nexport const APP_ROOT = __ROOT;\n\nexport const sleep = ms => new Promise(res => setTimeout(res, ms));\n\nexport function say(o) {\n  console.log(JSON.stringify(o));\n}\n\nexport function clone(o) {\n  return JSON.parse(JSON.stringify(o));\n}\n\nexport async function untilTrue(pred, waitOverride = MIN_WAIT, maxWaits = MAX_WAITS) {\n  if ( waitOverride < 0 ) {\n    maxWaits = -1;\n    waitOverride = MIN_WAIT;\n  }\n  let waitCount = 0;\n  let resolve;\n  const pr = new Promise(res => resolve = res);\n  setTimeout(checkPred, 0);\n  return pr;\n\n  async function checkPred() {\n    DEBUG.checkPred && console.log('Checking', pred.toString());\n    if ( await pred() ) {\n      return resolve(true);\n    } else {\n      waitCount++;\n      if ( waitCount < maxWaits || maxWaits < 0 ) {\n        setTimeout(checkPred, waitOverride);\n      } else {\n        resolve(false);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/gem-highlighter.js",
    "content": "// highlighter.js\n\nimport ukkonen from 'ukkonen';\nimport {DEBUG} from './common.js';\n\nconst MAX_ACCEPT_SCORE = 0.5;\nconst CHUNK_SIZE = 12; // Default, can be overridden\n\nfunction params(qLength, chunkSize = CHUNK_SIZE) {\n  // MaxDist is the maximum edit distance we're willing to consider for a chunk.\n  // If chunkSize is small, MaxDist might be too restrictive for longer queries.\n  // Consider making MaxDist also a function of qLength, e.g., Math.min(chunkSize, qLength / 2)\n  const MaxDist = chunkSize; // This was the original.\n  // A more flexible MaxDist could be:\n  // const MaxDist = Math.min(chunkSize, Math.floor(qLength * 0.4) + 1); // Allow up to 40% of query length as edits\n\n  // MinScore: if distance is 0, this is the \"best\" raw score.\n  // If qLength and chunkSize are very different, distance can't be 0.\n  // The distance between two strings is at least abs(len1 - len2).\n  const MinScore = Math.abs(qLength - chunkSize);\n\n  // MaxScore: used for scaling. (distance - MinScore) / MaxScore_Range\n  // The maximum possible distance is max(qLength, chunkSize).\n  // So, the range of distances is from MinScore to max(qLength, chunkSize).\n  // The length of this range is max(qLength, chunkSize) - MinScore.\n  const MaxScore_Range = Math.max(qLength, chunkSize) - MinScore;\n\n  // If MaxScore_Range is 0 (e.g., qLength === chunkSize, so MinScore is 0, and max distance is qLength),\n  // avoid division by zero. In this case, any distance > 0 is \"bad\".\n  // A distance of 0 would be a perfect match.\n  return {MaxDist, MinScore, MaxScore_Range: MaxScore_Range === 0 ? 1 : MaxScore_Range};\n}\n\n// Helper to wrap query terms with <mark> tags within a text\nfunction markText(text, query) {\n    if (!text || !query) return text;\n    try {\n        // Case-insensitive replacement\n        const regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n        return text.replace(regex, '<mark>$1</mark>');\n    } catch (e) {\n        // Regex might fail for complex queries, fallback to original text\n        console.warn(\"Marking text failed for query:\", query, e);\n        return text;\n    }\n}\n\nexport function highlight(query, doc, {\n  maxLength = 0,\n  maxAcceptScore = MAX_ACCEPT_SCORE,\n  chunkSize = CHUNK_SIZE,\n  // NEW options for server integration\n  around = '', // e.g. '<mark>' - but we'll handle this internally now\n  before = '', // e.g. '</mark>' - but we'll handle this internally now\n  numResults = 3, // How many top snippets to return\n  contextChars = 30 // How many characters before/after the matched chunk\n} = {}) {\n  if (chunkSize % 2 !== 0 && chunkSize !== 1) { // Allow chunkSize 1 for exact char matching if desired\n    // Original code threw error for odd chunkSize.\n    // Relaxing this slightly, but even is generally better for the overlapping strategy.\n    // For simplicity, let's stick to the original constraint or make it more robust.\n    // For now, let's assume it's usually even or we adjust.\n    // If we keep the original overlapping strategy, even chunkSize is important for the offset.\n    // Let's keep the original constraint for now:\n     if ( chunkSize % 2 ) {\n        console.warn(`highlight: chunkSize should ideally be even. Was: ${chunkSize}. Adjusting to ${chunkSize+1}`);\n        chunkSize = chunkSize + 1; // Or throw error as original\n     }\n  }\n\n  const originalDocString = doc; // Keep the original string for final snippet extraction\n  doc = Array.from(doc); // Work with array of characters for unicode safety\n\n  if (maxLength > 0 && doc.length > maxLength) {\n    doc = doc.slice(0, maxLength);\n  }\n\n  if (doc.length === 0 || query.trim() === \"\") {\n    return []; // No document or query, no highlights\n  }\n\n  const queryChars = Array.from(query.toLocaleLowerCase()); // Lowercase query once\n  const qLength = queryChars.length;\n\n  if (qLength === 0) return [];\n\n\n  // --- Fragment Generation ---\n  // The original code created two sets of fragments with different offsets.\n  // This is a strategy to catch matches that might fall across non-overlapping chunk boundaries.\n  // Let's simplify this for clarity first, then consider re-adding if necessary.\n  // A simpler approach: overlapping chunks.\n  const step = Math.max(1, Math.floor(chunkSize / 2)); // Create overlapping chunks\n  const fragments = [];\n  for (let i = 0; i <= doc.length - chunkSize; i += step) {\n    const fragmentTextChars = doc.slice(i, i + chunkSize);\n    fragments.push({\n      text: fragmentTextChars.join(''),\n      textChars: fragmentTextChars, // Keep char array for lowercase version\n      offset: i,\n      // symbols: doc // Reference to the full document character array (for context later)\n                      // This can be memory intensive if doc is huge.\n                      // We'll use originalDocString and offsets for context.\n    });\n  }\n  // Add last fragment if doc.length is not a multiple of step\n  if (doc.length % chunkSize !== 0 && doc.length > chunkSize) {\n      const i = Math.floor((doc.length - chunkSize)/step) * step; // last full step\n      if (i + chunkSize < doc.length) { // if there's a remainder smaller than chunkSize\n        const remainderOffset = i + step > doc.length - chunkSize ? doc.length - chunkSize : i + step;\n        if (remainderOffset < doc.length -1 && remainderOffset > 0) { // ensure it's a valid offset\n            const fragmentTextChars = doc.slice(remainderOffset, Math.min(remainderOffset + chunkSize, doc.length));\n             if (fragmentTextChars.length > 0) {\n                fragments.push({\n                    text: fragmentTextChars.join(''),\n                    textChars: fragmentTextChars,\n                    offset: remainderOffset,\n                });\n            }\n        }\n      } else if (doc.length < chunkSize) { // if doc is smaller than chunksize\n        // The loop for fragments won't run, so add the whole doc as one fragment\n        if (fragments.length === 0) {\n             fragments.push({\n                text: doc.join(''),\n                textChars: doc,\n                offset: 0,\n            });\n        }\n      }\n  }\n   if (fragments.length === 0 && doc.length > 0) { // Case: doc is shorter than chunkSize\n        fragments.push({\n            text: doc.join(''),\n            textChars: doc,\n            offset: 0,\n        });\n    }\n\n\n  DEBUG.verboseSlow && console.log(\"Generated fragments:\", fragments.length);\n\n  const { MaxDist, MinScore, MaxScore_Range } = params(qLength, chunkSize);\n\n  const scoredFragments = fragments.map(fragment => {\n    const fragmentTextLower = fragment.textChars.join('').toLocaleLowerCase();\n    // Ukkonen distance between the lowercase query and lowercase fragment text\n    const distance = ukkonen(queryChars.join(''), fragmentTextLower, MaxDist);\n    \n    // Scale the score: 0 is best (perfect match or close), 1 is worst (MaxDist or more)\n    // If distance is -1 (meaning it exceeded MaxDist), assign a very high score.\n    let scaledScore;\n    if (distance === -1) {\n        scaledScore = Infinity; // Or a value > 1, e.g., 2\n    } else {\n        // scaledScore = (distance - MinScore) / MaxScore_Range;\n        // Simpler scaling: distance / qLength (fraction of query that is \"wrong\")\n        // This makes maxAcceptScore more intuitive (e.g., 0.2 means up to 20% difference)\n        scaledScore = distance / Math.max(1, qLength); // Avoid division by zero for empty query (already handled)\n    }\n    \n    return { score: scaledScore, fragment };\n  });\n\n  // Sort by score (ascending, lower is better)\n  scoredFragments.sort((a, b) => a.score - b.score);\n\n  DEBUG.verboseSlow && console.log(\"Top 5 scored fragments:\", scoredFragments.slice(0, 5));\n\n  const bestHighlights = [];\n  const seenOffsets = new Set(); // To avoid overly similar/overlapping snippets\n\n  for (const { score, fragment } of scoredFragments) {\n    if (bestHighlights.length >= numResults * 2) break; // Get a slightly larger pool initially\n\n    if (score > maxAcceptScore) {\n      // If even the best scores are too high, we might not have good matches.\n      // However, if we have *some* results already, we might stop.\n      // If bestHighlights is empty and score > maxAcceptScore, then we have no good matches.\n      if (bestHighlights.length === 0 && score !== Infinity) { // If it's the first one and bad, but not impossible\n          // Potentially keep it if we want to *always* return something\n      } else if (score === Infinity || score > maxAcceptScore) {\n          continue; // Skip clearly bad or too fuzzy matches if we have better options\n      }\n    }\n    \n    // Check for overlap with already selected highlights\n    let isOverlapping = false;\n    for (const existingOffset of seenOffsets) {\n        if (Math.abs(fragment.offset - existingOffset) < chunkSize / 2) { // Heuristic for overlap\n            isOverlapping = true;\n            break;\n        }\n    }\n    if (isOverlapping) continue;\n\n    bestHighlights.push({ score, fragment });\n    seenOffsets.add(fragment.offset);\n  }\n  \n  DEBUG.verboseSlow && console.log(\"Filtered bestHighlights (before context/marking):\", bestHighlights.length);\n\n\n  if (bestHighlights.length === 0 && scoredFragments.length > 0 && scoredFragments[0].score !== Infinity) {\n    // If no highlights passed the filter but there was at least one scorable fragment,\n    // take the absolute best one, regardless of maxAcceptScore, to ensure we return *something*.\n    // This was the behavior of the original code's \"Zero highlights, showing first score\"\n    if (scoredFragments[0].fragment) { // Check if fragment exists\n        bestHighlights.push(scoredFragments[0]);\n         DEBUG.verboseSlow && console.log('No highlights passed filters, taking the absolute best scored fragment.');\n    }\n  }\n\n\n  // Now, construct the final snippets with context and <mark> tags\n  const finalSnippets = bestHighlights\n    .slice(0, numResults) // Take the top N results\n    .map(({ score, fragment }) => {\n      const start = Math.max(0, fragment.offset - contextChars);\n      const end = Math.min(originalDocString.length, fragment.offset + fragment.text.length + contextChars);\n      \n      let snippetText = originalDocString.substring(start, end);\n\n      // Apply <mark> tags. This is the crucial part for server integration.\n      // We mark the original query within this expanded snippet.\n      snippetText = markText(snippetText, query);\n\n      return {\n        // score, // Optionally include score if useful for UI\n        fragment: {\n          text: snippetText,\n          offset: fragment.offset, // Original offset of the core matched chunk\n          // No need for 'symbols' anymore in the returned fragment\n        }\n      };\n    });\n    \n  // The original code had a \"better\" loop that re-scored with more context.\n  // This can be useful but adds complexity. For now, the above provides context around the best chunks.\n  // If re-scoring is desired:\n  // 1. Take top N initial highlights.\n  // 2. For each, expand context (as done above).\n  // 3. Re-run Ukkonen on this expanded (but not yet marked) snippet.\n  // 4. Re-sort based on these new scores.\n  // 5. Then apply <mark> tags.\n  // This was what your \"better = better.map(hl => { ... })\" loop was doing.\n  // Let's defer re-implementing that precisely unless the current results are insufficient.\n\n  DEBUG.verboseSlow && console.log(\"Final snippets to return:\", finalSnippets);\n  return finalSnippets;\n}\n\n\n// --- trilight function (and its helper getFragmenter) ---\n// This function seems to be an alternative highlighting/segmentation strategy.\n// It's not directly used by the server's current highlight call, but I'll review it.\n\n// (getFragmenter is used by both highlight (implicitly if we restore original frag logic) and trilight)\n// returns a function that creates fragments\nfunction getFragmenter(chunkSize, {overlap = false, step = 1} = {}) {\n  if (!Number.isInteger(chunkSize) || chunkSize < 1) {\n    throw new TypeError(`chunkSize needs to be a whole number greater than 0`);\n  }\n  if (!Number.isInteger(step) || step < 1) {\n    throw new TypeError(`step needs to be a whole number greater than 0`);\n  }\n\n  // This function is complex due to its use of reduce and mutating frags array.\n  // A generator function or a simple loop might be clearer for fragment generation.\n  // However, let's keep its logic for now if it's specific to trilight's needs.\n\n  // The original getFragmenter was stateful in a way that's tricky with `reduce`\n  // if `overlap` is true and it tries to modify previous elements of `frags`.\n  // Let's simplify its signature and usage for `trilight` if it's only for n-grams.\n\n  // If for n-grams (overlap=true, step=1 typically for n-grams)\n  if (overlap) {\n    return function ngramFragmenter(frags, _nextSymbol, index, symbols) {\n      if (index <= symbols.length - chunkSize) {\n        const ngramChars = symbols.slice(index, index + chunkSize);\n        frags.push({\n          text: ngramChars.join(''),\n          offset: index,\n          // symbols: symbols // Avoid if not strictly needed or doc is large\n        });\n      }\n      return frags;\n    };\n  } else {\n    // Non-overlapping chunks (or controlled overlap via step)\n    // This is more like the fragment generation now in `highlight`\n    return function chunkFragmenter(frags, _nextSymbol, index, symbols) {\n        // This will be called for each symbol, which is inefficient for chunking.\n        // It's better to do chunking in a loop outside.\n        // For now, to match original structure if trilight depends on it:\n        if (index % chunkSize === 0) { // Start new chunk\n            const chunkChars = symbols.slice(index, Math.min(index + chunkSize, symbols.length));\n            if (chunkChars.length > 0) {\n                 frags.push({\n                    text: chunkChars.join(''),\n                    offset: index,\n                    // symbols: symbols\n                });\n            }\n        }\n        return frags;\n    };\n  }\n}\n\n\nexport function trilight(query, doc, {\n  maxLength = 0,\n  ngramSize = 3,\n  maxSegmentSize = 140,\n  numResults = 3 // How many segments to return\n} = {}) {\n  const originalDocString = doc; // For final slicing\n  query = Array.from(query.toLocaleLowerCase());\n  const docCharsLower = Array.from(doc.toLocaleLowerCase());\n  \n  let effectiveDoc = docCharsLower;\n  if (maxLength > 0 && effectiveDoc.length > maxLength) {\n    effectiveDoc = effectiveDoc.slice(0, maxLength);\n  }\n\n  if (effectiveDoc.length === 0 || query.length === 0 || query.length < ngramSize) {\n    return [];\n  }\n\n  // Generate n-grams for document and query\n  const docNgrams = [];\n  for (let i = 0; i <= effectiveDoc.length - ngramSize; i++) {\n    docNgrams.push({ text: effectiveDoc.slice(i, i + ngramSize).join(''), offset: i });\n  }\n\n  const queryNgrams = [];\n  for (let i = 0; i <= query.length - ngramSize; i++) {\n    queryNgrams.push({ text: query.slice(i, i + ngramSize).join(''), offset: i });\n  }\n  \n  if (docNgrams.length === 0 || queryNgrams.length === 0) return [];\n\n  // Index document n-grams\n  const docNgramIndex = new Map();\n  for (const ngram of docNgrams) {\n    if (!docNgramIndex.has(ngram.text)) {\n      docNgramIndex.set(ngram.text, []);\n    }\n    docNgramIndex.get(ngram.text).push(ngram.offset);\n  }\n\n  // Find matching n-gram sequences (Longest Common Subsequence of N-gram Offsets)\n  // This is essentially what your 'entries' and 'runs' logic is doing.\n  // It's finding diagonals in a dot plot of query n-gram index vs doc n-gram index.\n  const runs = [];\n  for (let qi = 0; qi < queryNgrams.length; qi++) {\n    const qNgramText = queryNgrams[qi].text;\n    const docOffsets = docNgramIndex.get(qNgramText);\n    if (docOffsets) {\n      for (const docOffset of docOffsets) {\n        // This is a potential start of a run.\n        // Try to extend it.\n        let currentRunLength = 1;\n        let qIdx = qi + 1;\n        let dIdx = docOffset + 1; // Next char, not next ngram offset\n                                  // Original logic: dDi = di - lastDi; if (dQi === 1 && dDi === 1)\n                                  // This implies matching characters, not just ngrams.\n                                  // Let's stick to ngram matching for runs.\n                                  // A \"run\" is a sequence of matching n-grams where their relative positions are maintained.\n                                  // q_ngram[i] matches d_ngram[j]\n                                  // q_ngram[i+1] matches d_ngram[j+1] (if ngramSize=1, this is char matching)\n                                  // q_ngram[i+k] matches d_ngram[j+k]\n\n        // To find runs more directly:\n        // For each match (q_ngram_idx, d_ngram_idx), the value (d_ngram_idx - q_ngram_idx) is constant along a diagonal.\n        // Group matches by this diagonal value. Then, within each diagonal, find longest contiguous sequences.\n      }\n    }\n  }\n  // The original 'runs' logic is quite specific. Let's try to replicate its intent.\n  // It finds consecutive n-grams that match with a consistent offset.\n  const entries = [];\n  queryNgrams.forEach((qNgram, qNgramIndex) => {\n    const docOffsets = docNgramIndex.get(qNgram.text);\n    if (docOffsets) {\n      docOffsets.forEach(docNgramActualOffset => {\n        entries.push({\n          qNgramIndex, // Index of the ngram in the query's ngram list\n          docNgramActualOffset, // Actual character offset in the document\n          text: qNgram.text // The ngram text itself\n        });\n      });\n    }\n  });\n\n  // Sort entries primarily by document offset, then by query ngram index\n  // This helps in identifying consecutive runs.\n  entries.sort((a, b) => {\n    if (a.docNgramActualOffset !== b.docNgramActualOffset) {\n      return a.docNgramActualOffset - b.docNgramActualOffset;\n    }\n    return a.qNgramIndex - b.qNgramIndex;\n  });\n  \n  const identifiedRuns = [];\n  if (entries.length > 0) {\n    let currentRun = {\n        startDocOffset: entries[0].docNgramActualOffset,\n        startQueryNgramIndex: entries[0].qNgramIndex,\n        lengthNgrams: 1, // Length in terms of number of ngrams\n        // ngrams: [entries[0].text] // For debugging\n    };\n\n    for (let i = 1; i < entries.length; i++) {\n        const prevEntry = entries[i-1];\n        const currentEntry = entries[i];\n\n        // Check for contiguity:\n        // Query ngrams are consecutive: currentEntry.qNgramIndex === prevEntry.qNgramIndex + 1\n        // Document ngrams are consecutive (offsets advance by 1 for each char in ngram):\n        // currentEntry.docNgramActualOffset === prevEntry.docNgramActualOffset + 1 (if ngrams overlap by n-1)\n        // This is the condition from your original code: dQi === 1 && dDi === 1\n        // where dDi was char offset difference.\n        if (currentEntry.qNgramIndex === (currentRun.startQueryNgramIndex + currentRun.lengthNgrams) &&\n            currentEntry.docNgramActualOffset === (currentRun.startDocOffset + currentRun.lengthNgrams) ) {\n            currentRun.lengthNgrams++;\n            // currentRun.ngrams.push(currentEntry.text);\n        } else {\n            // End of current run, save it\n            identifiedRuns.push({\n                docOffset: currentRun.startDocOffset,\n                queryNgramStartIndex: currentRun.startQueryNgramIndex,\n                // Actual character length of the run in the document:\n                // start offset + (num_ngrams - 1) for overlaps + ngramSize for the last one\n                docLengthChars: currentRun.lengthNgrams + ngramSize - 1,\n                numMatchingNgrams: currentRun.lengthNgrams\n            });\n            // Start a new run\n            currentRun = {\n                startDocOffset: currentEntry.docNgramActualOffset,\n                startQueryNgramIndex: currentEntry.qNgramIndex,\n                lengthNgrams: 1,\n                // ngrams: [currentEntry.text]\n            };\n        }\n    }\n    // Push the last run\n    identifiedRuns.push({\n        docOffset: currentRun.startDocOffset,\n        queryNgramStartIndex: currentRun.startQueryNgramIndex,\n        docLengthChars: currentRun.lengthNgrams + ngramSize - 1,\n        numMatchingNgrams: currentRun.lengthNgrams\n    });\n  }\n  \n  DEBUG.verboseSlow && console.log(\"Trilight identifiedRuns:\", identifiedRuns);\n\n  // The original code then merges runs based on 'gaps'. This is a form of segment clustering.\n  // Let's simplify: take the longest runs as primary segments.\n  // Sort runs by numMatchingNgrams (as a proxy for quality/length)\n  identifiedRuns.sort((a, b) => b.numMatchingNgrams - a.numMatchingNgrams);\n\n  const finalSegments = [];\n  const addedRunOffsets = new Set();\n\n  for (const run of identifiedRuns) {\n    if (finalSegments.length >= numResults) break;\n\n    // Avoid adding segments that heavily overlap with already chosen ones\n    let overlaps = false;\n    for(let i = run.docOffset; i < run.docOffset + run.docLengthChars; i++) {\n        if (addedRunOffsets.has(i)) {\n            overlaps = true;\n            break;\n        }\n    }\n    if (overlaps && finalSegments.length > 0) continue; // Allow first segment even if it's the only one\n\n    const segmentStart = run.docOffset;\n    const segmentEnd = run.docOffset + run.docLengthChars;\n    \n    // Ensure segment does not exceed maxSegmentSize (original logic was more complex during merging)\n    // Here, we just check the individual run. If merging is desired, it's more complex.\n    if (run.docLengthChars > maxSegmentSize) {\n        // If a single best run is too long, we might truncate it or skip it.\n        // For now, let's allow it but be aware.\n        // Or, we could try to find a sub-segment centered around the query.\n    }\n\n    let text = originalDocString.substring(segmentStart, Math.min(segmentEnd, originalDocString.length));\n    \n    // Mark the text within this segment\n    text = markText(text, query.join('')); // query is array of chars\n\n    finalSegments.push({\n        fragment: { text, offset: segmentStart } // Keep consistent with `highlight` output\n    });\n\n    for(let i = run.docOffset; i < run.docOffset + run.docLengthChars; i++) {\n        addedRunOffsets.add(i);\n    }\n  }\n  \n  DEBUG.verboseSlow && console.log(\"Trilight finalSegments:\", finalSegments);\n\n  // If no segments, return a small part of the beginning of the document as a fallback\n  if (finalSegments.length === 0 && originalDocString.length > 0) {\n    DEBUG.verboseSlow && console.log(\"Trilight: No segments found, returning beginning of doc.\");\n    let fallbackText = originalDocString.substring(0, Math.min(maxSegmentSize, originalDocString.length));\n    fallbackText = markText(fallbackText, query.join(''));\n    return [{ fragment: { text: fallbackText, offset: 0 } }];\n  }\n\n  return finalSegments;\n}\n"
  },
  {
    "path": "src/hello.js",
    "content": "console.log(`hello...is it me you're looking for?`);\n"
  },
  {
    "path": "src/highlighter.js",
    "content": "// highlighter.js\n\nimport ukkonen from 'ukkonen';\nimport {DEBUG} from './common.js';\n\nconst MAX_ACCEPT_SCORE = 0.5;\nconst CHUNK_SIZE = 12;\n\n// Helper to wrap query terms with <mark> tags within a text\n// This function will be used by both highlight and trilight before returning results.\nfunction internalMarkText(textToMark, queryToFind) {\n    if (!textToMark || !queryToFind) return textToMark;\n    try {\n        // Case-insensitive replacement, escaping regex special characters in query\n        const escapedQuery = queryToFind.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n        const regex = new RegExp('(' + escapedQuery + ')', 'gi');\n        return textToMark.replace(regex, '<mark>$1</mark>');\n    } catch (e) {\n        console.warn(\"internalMarkText: Regex failed for query:\", queryToFind, e);\n        return textToMark; // Fallback to original text if regex fails\n    }\n}\n\n\nfunction calculateUkkonenParams(queryLength, chunkSize = CHUNK_SIZE) {\n  // Renamed from 'params' for clarity\n  const maxDistance = chunkSize; // Max edit distance for Ukkonen\n  const minPossibleScore = Math.abs(queryLength - chunkSize); // Minimum edits based on length difference\n  // Max possible score range (denominator for scaling)\n  let maxScoreRange = Math.max(queryLength, chunkSize) - minPossibleScore;\n  if (maxScoreRange === 0) maxScoreRange = 1; // Avoid division by zero\n\n  return {maxDistance, minPossibleScore, maxScoreRange};\n}\n\nexport function highlight(query, docString, {\n  maxLength = 0,\n  maxAcceptScore = MAX_ACCEPT_SCORE,\n  chunkSize = CHUNK_SIZE,\n  // Options from server (around, before) are now handled internally by <mark>\n  // numResults and contextChars are effectively handled by the original logic's\n  // \"better.slice(0,3)\" and \"extra\" context respectively.\n} = {}) {\n  if (chunkSize % 2) {\n    // Original code threw an error. Preserving this behavior.\n    throw new TypeError(`chunkSize must be even. Was: ${chunkSize} which is odd.`);\n  }\n\n  let docChars = Array.from(docString); // Use character array for Unicode safety\n  if (maxLength > 0 && docChars.length > maxLength) {\n    docChars = docChars.slice(0, maxLength);\n  }\n\n  if (docChars.length === 0 || query.trim() === \"\") {\n    return [];\n  }\n\n  const queryLower = query.toLocaleLowerCase(); // Lowercase query once\n  const queryLength = Array.from(query).length; // Unicode-safe query length\n\n  if (queryLength === 0) return [];\n\n  const {maxDistance, minPossibleScore, maxScoreRange} = calculateUkkonenParams(queryLength, chunkSize);\n\n  // --- Fragment Generation (Identical to original) ---\n  // First set of fragments (docChars1)\n  const docChars1 = [...docChars]; // Create a mutable copy\n  // Pad to make length a multiple of chunkSize\n  const padding1Length = (chunkSize - docChars1.length % chunkSize) % chunkSize;\n  docChars1.push(...Array(padding1Length).fill(' '));\n  const fragments1 = docChars1.reduce(getFragmenter(chunkSize, {symbolsArray: docChars}), []); // Pass original docChars for context\n\n  // Second set of fragments (docChars2) with offset\n  const docChars2 = [...docChars]; // Create another mutable copy\n  // Pad start by half chunkSize\n  docChars2.splice(0, 0, ...Array(chunkSize / 2).fill(' '));\n  // Pad end to make length a multiple of chunkSize\n  const padding2Length = (chunkSize - docChars2.length % chunkSize) % chunkSize;\n  docChars2.push(...Array(padding2Length).fill(' '));\n  const fragments2 = docChars2.reduce(getFragmenter(chunkSize, {symbolsArray: docChars, initialOffset: -(chunkSize/2)}), []); // Adjust offset\n\n  DEBUG.verboseSlow && console.log(\"highlight: fragments1 count:\", fragments1.length, \"fragments2 count:\", fragments2.length);\n\n  const allFragments = [...fragments1, ...fragments2];\n  const scoredFragments = allFragments.map(fragment => {\n    // fragment.text is already from the original doc, no need to lowercase it here for distance calculation\n    // ukkonen should compare queryLower with fragment.text.toLocaleLowerCase()\n    const distance = ukkonen(queryLower, fragment.text.toLocaleLowerCase(), maxDistance);\n    \n    let scaledScore;\n    if (distance === -1) { // Exceeded maxDistance\n        scaledScore = Infinity;\n    } else {\n        scaledScore = (distance - minPossibleScore) / maxScoreRange;\n    }\n    return {score: scaledScore, fragment}; // fragment object contains {text, offset, symbols}\n  });\n\n  // Sort ascending (smallest scores win)\n  scoredFragments.sort((a, b) => a.score - b.score);\n\n  const initialHighlights = [];\n  for (const {score, fragment} of scoredFragments) {\n    if (score > maxAcceptScore) {\n      // If we already have some highlights, we can stop if scores get too bad.\n      // If we have none, we might continue to find at least one, even if poor.\n      if (initialHighlights.length > 0) break; \n    }\n    initialHighlights.push({score, fragment});\n    if (initialHighlights.length >= 10 + 1) break; // Get a bit more than needed for the \"better\" selection (original took 10 for \"better\")\n  }\n  \n  DEBUG.verboseSlow && console.log(\"highlight: initialHighlights count:\", initialHighlights.length);\n\n  let topSnippets;\n\n  if (initialHighlights.length === 0) {\n    DEBUG.verboseSlow && console.log('highlight: Zero initial highlights. Considering first scored fragment if available.');\n    // Original logic: scores.slice(0,1) - this implies taking the best raw score if no \"good\" highlights\n    if (scoredFragments.length > 0 && scoredFragments[0].score !== Infinity) {\n        // Take the single best fragment, expand context, and mark it.\n        const bestFragment = scoredFragments[0].fragment;\n        const contextChars = chunkSize; // Original 'extra' was chunkSize\n        const start = Math.max(0, bestFragment.offset - contextChars);\n        const end = Math.min(docChars.length, bestFragment.offset + Array.from(bestFragment.text).length + contextChars);\n        const snippetText = docChars.slice(start, end).join('');\n        \n        topSnippets = [{\n            // score: scoredFragments[0].score, // Keep score if needed\n            fragment: {\n                text: internalMarkText(snippetText, query),\n                offset: bestFragment.offset // Original offset of the core matched chunk\n            }\n        }];\n    } else {\n        topSnippets = []; // Truly no usable fragments\n    }\n  } else {\n    // --- \"Better\" loop for context expansion and re-scoring (Identical logic to original) ---\n    const contextCharsForBetterLoop = chunkSize; // Original 'extra' was chunkSize\n    let betterScoredSnippets = initialHighlights.slice(0, 10).map(hl => {\n      const originalFragment = hl.fragment;\n      const originalFragmentTextChars = Array.from(originalFragment.text); // Unicode safe length\n      const originalFragmentLength = originalFragmentTextChars.length;\n\n      // Expand context using original document characters (hl.fragment.symbols)\n      const startContext = Math.max(0, originalFragment.offset - contextCharsForBetterLoop);\n      const endContext = Math.min(originalFragment.symbols.length, originalFragment.offset + originalFragmentLength + contextCharsForBetterLoop);\n      \n      const expandedText = originalFragment.symbols.slice(startContext, endContext).join('');\n      const expandedTextLength = Array.from(expandedText).length; // Unicode safe\n\n      // Re-calculate Ukkonen parameters for this new expanded text against the query\n      const {\n          maxDistance: newMaxDist, \n          minPossibleScore: newMinScore, \n          maxScoreRange: newMaxScoreRange\n      } = calculateUkkonenParams(queryLength, expandedTextLength); // chunkSize is now expandedTextLength\n\n      const newDistance = ukkonen(queryLower, expandedText.toLocaleLowerCase(), newMaxDist);\n      \n      let newScaledScore;\n      if (newDistance === -1) {\n          newScaledScore = Infinity;\n      } else {\n          newScaledScore = (newDistance - newMinScore) / newMaxScoreRange;\n      }\n      \n      // The fragment text for output is the expanded text\n      return {\n          score: newScaledScore, \n          fragment: { // New fragment object\n              text: expandedText, // This text will be marked later\n              // The offset should ideally be the start of this expanded snippet in the original document\n              offset: startContext, \n              // symbols: originalFragment.symbols // Not needed in final output\n          }\n      };\n    });\n\n    betterScoredSnippets.sort((a, b) => a.score - b.score);\n    DEBUG.verboseSlow && console.log(\"highlight: betterScoredSnippets (after re-scoring with context):\", JSON.stringify(betterScoredSnippets.slice(0,3),null,2));\n    \n    // Take top 3 from these \"better\" snippets and apply marking\n    topSnippets = betterScoredSnippets.slice(0, 3).map(item => ({\n        // score: item.score, // Keep score if needed\n        fragment: {\n            text: internalMarkText(item.fragment.text, query),\n            offset: item.fragment.offset\n        }\n    }));\n  }\n  \n  DEBUG.verboseSlow && console.log(\"highlight: final topSnippets to return:\", topSnippets);\n  return topSnippets;\n}\n\n\n// --- getFragmenter (Helper for highlight and trilight) ---\n// Preserving its original logic as much as possible, with clearer parameters.\n// The `symbolsArray` and `initialOffset` are for `highlight`'s specific needs.\nfunction getFragmenter(chunkSize, {overlap = false, symbolsArray = null, initialOffset = 0} = {}) {\n  if (!Number.isInteger(chunkSize) || chunkSize < 1) {\n    throw new TypeError(`chunkSize needs to be a whole number greater than 0`);\n  }\n\n  let currentFragmentCharCount; // Renamed from currentLength for clarity\n\n  return function fragmentReducer(fragmentsAccumulator, nextCharSymbol, charIndex, fullSymbolArray) {\n    // `fullSymbolArray` is the array being reduced.\n    // `symbolsArray` (passed in options) is the *original* document characters,\n    // used by `highlight` to ensure fragment.symbols points to the original doc.\n    const effectiveSymbolsArray = symbolsArray || fullSymbolArray;\n    const effectiveCharIndex = charIndex + initialOffset; // Adjust index for highlight's second pass\n\n    if (overlap) {\n      // Logic for overlapping fragments (primarily for trilight's n-grams)\n      // This part of original getFragmenter was complex and seemed to modify previous frags.\n      // For n-grams, it's simpler: create a new fragment for each possible n-gram.\n      if (charIndex <= fullSymbolArray.length - chunkSize) {\n        const ngramChars = fullSymbolArray.slice(charIndex, charIndex + chunkSize);\n        fragmentsAccumulator.push({\n          text: ngramChars.join(''),\n          offset: effectiveCharIndex, // Offset in the original document\n          symbols: effectiveSymbolsArray\n        });\n      }\n    } else {\n      // Logic for non-overlapping fragments (for highlight's chunking)\n      if (fragmentsAccumulator.length === 0 || currentFragmentCharCount >= chunkSize) {\n        // Start a new fragment\n        fragmentsAccumulator.push({\n          text: nextCharSymbol,\n          offset: effectiveCharIndex, // Offset in the original document\n          symbols: effectiveSymbolsArray\n        });\n        currentFragmentCharCount = 1;\n      } else {\n        // Add to the current fragment\n        const currentFragment = fragmentsAccumulator[fragmentsAccumulator.length - 1];\n        currentFragment.text += nextCharSymbol;\n        currentFragmentCharCount++;\n      }\n    }\n    return fragmentsAccumulator;\n  };\n}\n\n\n// --- trilight function ---\n// Preserving original algorithm and segment generation logic with clarity and <mark> support.\nexport function trilight(query, docString, {\n  maxLength = 0,\n  ngramSize = 3,\n  maxSegmentSize = 140,\n  // numResults is implicitly 3 due to .slice(0,3) at the end\n} = {}) {\n  const originalDocChars = Array.from(docString); // For final slicing, Unicode safe\n  const queryChars = Array.from(query.toLocaleLowerCase()); // Lowercase query once\n  \n  let docCharsForProcessing = Array.from(docString.toLocaleLowerCase());\n  if (maxLength > 0 && docCharsForProcessing.length > maxLength) {\n    docCharsForProcessing = docCharsForProcessing.slice(0, maxLength);\n  }\n\n  if (docCharsForProcessing.length < ngramSize || queryChars.length < ngramSize) {\n    return [];\n  }\n\n  // Generate n-grams for document and query using the getFragmenter\n  // For n-grams, getFragmenter should be called with overlap: true\n  const docNgrams = docCharsForProcessing.reduce(getFragmenter(ngramSize, {overlap: true, symbolsArray: originalDocChars}), []);\n  const queryNgrams = queryChars.reduce(getFragmenter(ngramSize, {overlap: true, symbolsArray: queryChars}), []); // symbolsArray here is queryChars\n\n  if (docNgrams.length === 0 || queryNgrams.length === 0) return [];\n\n  // Index document n-grams by their text\n  const docNgramIndex = new Map();\n  docNgrams.forEach(ngram => {\n    if (!docNgramIndex.has(ngram.text)) {\n      docNgramIndex.set(ngram.text, []);\n    }\n    // Store original character offset of the ngram in the document\n    docNgramIndex.get(ngram.text).push(ngram.offset);\n  });\n\n  // --- Find matching entries (Identical to original logic) ---\n  const matchingEntries = [];\n  queryNgrams.forEach((queryNgram, queryNgramIndex) => {\n    const docOffsetsForNgram = docNgramIndex.get(queryNgram.text);\n    if (docOffsetsForNgram) {\n      docOffsetsForNgram.forEach(docCharOffset => {\n        matchingEntries.push({\n          ngramText: queryNgram.text,\n          queryNgramIndex: queryNgramIndex, // Index of ngram within queryNgrams list\n          docCharOffset: docCharOffset    // Character offset of ngram in original document\n        });\n      });\n    }\n  });\n  matchingEntries.sort((a, b) => a.docCharOffset - b.docCharOffset); // Sort by document offset\n\n  // --- Identify runs of consecutive matching n-grams (Identical to original logic) ---\n  const runs = [];\n  if (matchingEntries.length > 0) {\n    let currentRun = {\n      ngramsInRun: [matchingEntries[0].ngramText],\n      startQueryNgramIndex: matchingEntries[0].queryNgramIndex,\n      startDocCharOffset: matchingEntries[0].docCharOffset\n    };\n    let lastQueryNgramIndexInRun = matchingEntries[0].queryNgramIndex;\n    let lastDocCharOffsetInRun = matchingEntries[0].docCharOffset;\n\n    for (let i = 1; i < matchingEntries.length; i++) {\n      const entry = matchingEntries[i];\n      const queryIndexDiff = entry.queryNgramIndex - lastQueryNgramIndexInRun;\n      const docOffsetDiff = entry.docCharOffset - lastDocCharOffsetInRun;\n\n      if (queryIndexDiff === 1 && docOffsetDiff === 1) { // Consecutive in both query and doc\n        currentRun.ngramsInRun.push(entry.ngramText);\n      } else {\n        // End current run, add its length, then push\n        currentRun.charLengthInDoc = currentRun.ngramsInRun.length + (ngramSize - 1);\n        runs.push(currentRun);\n        // Start new run\n        currentRun = {\n          ngramsInRun: [entry.ngramText],\n          startQueryNgramIndex: entry.queryNgramIndex,\n          startDocCharOffset: entry.docCharOffset\n        };\n      }\n      lastQueryNgramIndexInRun = entry.queryNgramIndex;\n      lastDocCharOffsetInRun = entry.docCharOffset;\n    }\n    // Add the last run\n    currentRun.charLengthInDoc = currentRun.ngramsInRun.length + (ngramSize - 1);\n    runs.push(currentRun);\n  }\n  \n  DEBUG.verboseSlow && console.log(\"trilight: identified runs:\", runs.length);\n\n  // --- Calculate gaps between runs (Identical to original logic) ---\n  const gaps = [];\n  if (runs.length > 1) {\n    for (let i = 0; i < runs.length - 1; i++) {\n      const run1 = runs[i];\n      const run2 = runs[i+1];\n      gaps.push({\n        connectedRuns: [run1, run2],\n        gapSize: run2.startDocCharOffset - (run1.startDocCharOffset + run1.charLengthInDoc)\n      });\n    }\n  }\n  gaps.sort((a, b) => a.gapSize - b.gapSize); // Sort by smallest gap\n\n  // --- Merge runs into segments (Identical to original logic) ---\n  const segments = [];\n  const runToSegmentMap = new Map(); // Maps run's startDocCharOffset to the segment it belongs to\n\n  // Initialize segments with individual runs if they are not too long\n  runs.forEach(run => {\n      if (run.charLengthInDoc <= maxSegmentSize) {\n          const newSegment = {\n              startOffset: run.startDocCharOffset,\n              endOffset: run.startDocCharOffset + run.charLengthInDoc,\n              score: run.charLengthInDoc // Initial score is its own length\n          };\n          segments.push(newSegment);\n          runToSegmentMap.set(run.startDocCharOffset, newSegment);\n      }\n  });\n\n\n  for (const gapInfo of gaps) {\n    const runLeft = gapInfo.connectedRuns[0];\n    const runRight = gapInfo.connectedRuns[1];\n\n    const segmentForLeftRun = runToSegmentMap.get(runLeft.startDocCharOffset);\n    const segmentForRightRun = runToSegmentMap.get(runRight.startDocCharOffset);\n\n    if (segmentForLeftRun && segmentForRightRun && segmentForLeftRun === segmentForRightRun) {\n      continue; // Already in the same segment\n    }\n\n    let merged = false;\n    if (segmentForLeftRun && !segmentForRightRun) { // Try to extend left segment with right run\n      const potentialNewEnd = runRight.startDocCharOffset + runRight.charLengthInDoc;\n      if ((potentialNewEnd - segmentForLeftRun.startOffset) <= maxSegmentSize) {\n        segmentForLeftRun.endOffset = potentialNewEnd;\n        segmentForLeftRun.score += runRight.charLengthInDoc; // Add length of right run\n        runToSegmentMap.set(runRight.startDocCharOffset, segmentForLeftRun); // Right run now points to left's segment\n        // Remove standalone segment for right run if it existed (it shouldn't if !segmentForRightRun)\n        const rightRunStandaloneSegmentIndex = segments.findIndex(s => s.startOffset === runRight.startDocCharOffset && s.endOffset === runRight.startDocCharOffset + runRight.charLengthInDoc);\n        if (rightRunStandaloneSegmentIndex > -1) segments.splice(rightRunStandaloneSegmentIndex, 1);\n        merged = true;\n      }\n    } else if (!segmentForLeftRun && segmentForRightRun) { // Try to extend right segment with left run\n      const potentialNewStart = runLeft.startDocCharOffset;\n      if ((segmentForRightRun.endOffset - potentialNewStart) <= maxSegmentSize) {\n        segmentForRightRun.startOffset = potentialNewStart;\n        segmentForRightRun.score += runLeft.charLengthInDoc;\n        runToSegmentMap.set(runLeft.startDocCharOffset, segmentForRightRun);\n        const leftRunStandaloneSegmentIndex = segments.findIndex(s => s.startOffset === runLeft.startDocCharOffset && s.endOffset === runLeft.startDocCharOffset + runLeft.charLengthInDoc);\n        if (leftRunStandaloneSegmentIndex > -1) segments.splice(leftRunStandaloneSegmentIndex, 1);\n        merged = true;\n      }\n    } else if (segmentForLeftRun && segmentForRightRun) { // Both runs are in existing (different) segments, try to merge these segments\n        const potentialNewLength = segmentForRightRun.endOffset - segmentForLeftRun.startOffset;\n        if (potentialNewLength <= maxSegmentSize) {\n            segmentForLeftRun.endOffset = segmentForRightRun.endOffset;\n            segmentForLeftRun.score += segmentForRightRun.score; // Combine scores\n\n            // All runs that were part of segmentForRightRun now point to segmentForLeftRun\n            for (const [runStartOffset, seg] of runToSegmentMap.entries()) {\n                if (seg === segmentForRightRun) {\n                    runToSegmentMap.set(runStartOffset, segmentForLeftRun);\n                }\n            }\n            // Remove segmentForRightRun from segments array\n            const rightSegmentIndex = segments.indexOf(segmentForRightRun);\n            if (rightSegmentIndex > -1) segments.splice(rightSegmentIndex, 1);\n            merged = true;\n        }\n    }\n    // Original code also had a case for creating a new segment from two runs not yet in segments.\n    // This is covered by the initialization of segments with individual runs, and then merging.\n    // The provided logic for merging was:\n    // else { /* if (!leftSeg && !rightSeg) */\n    //   const newSegment = { start: runs[0].di, end: runs[0].di + runs[0].length + nextGap.gap + runs[1].length, score: runs[0].length + runs[1].length };\n    //   if ( newSegment.end - newSegment.start <= maxSegmentSize ) { runSegMap[runs[0].di] = newSegment; runSegMap[runs[1].di] = newSegment; segments.push(newSegment); assigned = newSegment; }\n    // }\n    // This specific \"else\" is tricky to map directly if segments are pre-initialized.\n    // The current merging logic tries to extend existing segments. If two runs are not in segments\n    // and their combined length (including gap) is <= maxSegmentSize, they should form a new segment.\n    // This is implicitly handled if they were small enough to be individual segments initially and then get merged.\n    // The key is that `runToSegmentMap` correctly tracks which segment a run belongs to.\n\n    if (merged) {\n      DEBUG.verboseSlow && console.log('trilight: Merged gap, new segment length:', segmentForLeftRun ? segmentForLeftRun.endOffset - segmentForLeftRun.startOffset : segmentForRightRun.endOffset - segmentForRightRun.startOffset);\n    } else {\n      DEBUG.verboseSlow && console.log('trilight: Gap could not be merged or runs not in mappable segments.');\n    }\n  }\n  \n  // Deduplicate segments that might have become identical after merges (e.g., if map pointed multiple runs to same segment object)\n  const uniqueSegments = Array.from(new Set(segments.filter(s => s))); // Filter out undefined/null if any\n  uniqueSegments.sort((a, b) => b.score - a.score); // Sort by score (descending)\n\n  const textSegments = uniqueSegments.slice(0, 3).map(segment => {\n    const snippetText = originalDocChars.slice(segment.startOffset, segment.endOffset).join('');\n    return { // Return in the same format as highlight()\n        fragment: {\n            text: internalMarkText(snippetText, query),\n            offset: segment.startOffset\n        }\n    };\n  });\n\n  DEBUG.verboseSlow && console.log(\"trilight: final textSegments:\", textSegments.length);\n\n  if (textSegments.length === 0 && originalDocChars.length > 0) {\n    DEBUG.verboseSlow && console.log(\"trilight: No segments found, returning beginning of doc.\");\n    const fallbackText = originalDocChars.slice(0, Math.min(maxSegmentSize, originalDocChars.length)).join('');\n    return [{ fragment: { text: internalMarkText(fallbackText, query), offset: 0 } }];\n  }\n\n  return textSegments;\n}\n"
  },
  {
    "path": "src/index.js",
    "content": " \nrequire = require('esm')(module/*, options*/);\nmodule.exports = require('./app.js');\n \n"
  },
  {
    "path": "src/installBrowser.js",
    "content": "import { exec } from 'child_process';\nimport { promisify } from 'util';\nimport { createWriteStream } from 'fs';\nimport { pipeline } from 'stream/promises';\nimport { readFile } from 'fs/promises';\nimport https from 'node:https';\n\n// Constants\nconst execPromise = promisify(exec);\n\nconst SUPPORTED_BROWSERS = ['chrome', 'brave', 'vivaldi', 'edge', 'chromium'];\nconst PLATFORM = process.platform; // 'win32', 'darwin', 'linux'\nconst ARCH = process.arch; // 'x64', 'arm64', etc.\n\n// Logic\n// None; function is exported for use\n\n// Functions\nexport async function installBrowser(browserName) {\n  if (!SUPPORTED_BROWSERS.includes(browserName)) {\n    throw new Error(`Unsupported browser: ${browserName}. Supported: ${SUPPORTED_BROWSERS.join(', ')}`);\n  }\n\n  console.log(`Installing ${browserName} on ${PLATFORM} (${ARCH})...`);\n\n  await checkBrowserAvailability(browserName);\n  const binaryPath = await installBrowserForPlatform(browserName);\n  console.log(`${browserName} installed at: ${binaryPath}`);\n  return binaryPath;\n}\n\nasync function checkBrowserAvailability(browserName) {\n  if (PLATFORM === 'linux' && ARCH === 'arm64' && browserName === 'chrome') {\n    throw new Error('Chrome is not available for ARM64 Linux. Try Brave or Chromium instead.');\n  }\n  // Add more checks for other browsers if needed (e.g., Vivaldi ARM64 stability)\n}\n\nasync function installBrowserForPlatform(browserName) {\n  if (PLATFORM === 'win32') {\n    return await installOnWindows(browserName);\n  } else if (PLATFORM === 'darwin') {\n    return await installOnMacOS(browserName);\n  } else if (PLATFORM === 'linux') {\n    return await installOnLinux(browserName);\n  } else {\n    throw new Error(`Unsupported platform: ${PLATFORM}`);\n  }\n}\n\nasync function installOnWindows(browserName) {\n  try {\n    // Check if winget is installed\n    try {\n      await execPromise('winget --version');\n    } catch {\n      console.log('winget not found. Installing winget...');\n      await execPromise('powershell -Command \"irm asheroto.com/winget | iex\"');\n      console.log('winget installed successfully.');\n    }\n\n    if (browserName === 'chrome') {\n      await execPromise('winget install Google.Chrome --silent --accept-package-agreements --accept-source-agreements');\n      return 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe';\n    } else if (browserName === 'brave') {\n      await execPromise('winget install Brave.Brave --silent --accept-package-agreements --accept-source-agreements');\n      return 'C:\\\\Program Files\\\\BraveSoftware\\\\Brave-Browser\\\\Application\\\\brave.exe';\n    } else if (browserName === 'vivaldi') {\n      await execPromise('winget install Vivaldi.Vivaldi --silent --accept-package-agreements --accept-source-agreements');\n      return 'C:\\\\Program Files\\\\Vivaldi\\\\Application\\\\vivaldi.exe';\n    } else if (browserName === 'edge') {\n      await execPromise('winget install Microsoft.Edge --silent --accept-package-agreements --accept-source-agreements');\n      return 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe';\n    } else if (browserName === 'chromium') {\n      const url = getDownloadUrl(browserName, PLATFORM, ARCH);\n      if (!url) throw new Error('Chromium download not supported on Windows');\n      const outputPath = 'C:\\\\Program Files\\\\Chromium\\\\chromium.exe';\n      await downloadBinary(url, outputPath);\n      return outputPath;\n    }\n  } catch (error) {\n    console.error(`Windows install failed: ${error.message}`);\n    throw error;\n  }\n}\n\nasync function installOnMacOS(browserName) {\n  try {\n    // Check if brew is installed\n    try {\n      await execPromise('brew --version');\n    } catch {\n      throw new Error('Homebrew is not installed. Please install it from https://brew.sh and try again.');\n    }\n\n    if (browserName === 'chrome') {\n      await execPromise('brew install --cask google-chrome');\n      return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';\n    } else if (browserName === 'brave') {\n      await execPromise('brew install --cask brave-browser');\n      return '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser';\n    } else if (browserName === 'vivaldi') {\n      await execPromise('brew install --cask vivaldi');\n      return '/Applications/Vivaldi.app/Contents/MacOS/Vivaldi';\n    } else if (browserName === 'edge') {\n      await execPromise('brew install --cask microsoft-edge');\n      return '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';\n    } else if (browserName === 'chromium') {\n      await execPromise('brew install --cask chromium');\n      return '/Applications/Chromium.app/Contents/MacOS/Chromium';\n    }\n  } catch (error) {\n    console.error(`macOS install failed: ${error.message}`);\n    throw error;\n  }\n}\n\nasync function installOnLinux(browserName) {\n  try {\n    const distro = await getLinuxDistro();\n    if (distro === 'debian') {\n      return await installOnDebian(browserName);\n    } else if (distro === 'fedora') {\n      return await installOnFedora(browserName);\n    } else {\n      throw new Error(`Unsupported Linux distribution: ${distro}`);\n    }\n  } catch (error) {\n    console.error(`Linux install failed: ${error.message}`);\n    throw error;\n  }\n}\n\nasync function installOnDebian(browserName) {\n  let binaryPath = '/usr/bin/' + browserName;\n  if (browserName === 'chrome') {\n    await execPromise('wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -');\n    await execPromise(`sudo sh -c 'echo \"deb [arch=${ARCH === 'arm64' ? 'arm64' : 'amd64'}] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google-chrome.list'`);\n    await execPromise('sudo apt-get update && sudo apt-get install -y google-chrome-stable');\n    binaryPath = '/usr/bin/google-chrome';\n  } else if (browserName === 'brave') {\n    await execPromise('sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg');\n    await execPromise(`echo \"deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg arch=${ARCH === 'arm64' ? 'arm64' : 'amd64'}] https://brave-browser-apt-release.s3.brave.com/ stable main\" | sudo tee /etc/apt/sources.list.d/brave-browser-release.list`);\n    await execPromise('sudo apt update && sudo apt install -y brave-browser');\n    binaryPath = '/usr/bin/brave-browser';\n  } else if (browserName === 'vivaldi') {\n    await execPromise('wget -qO- https://repo.vivaldi.com/archive/linux_signing_key.pub | sudo apt-key add -');\n    await execPromise(`sudo add-apt-repository \"deb [arch=${ARCH === 'arm64' ? 'arm64' : 'amd64'}] https://repo.vivaldi.com/archive/deb/ stable main\"`);\n    await execPromise('sudo apt update && sudo apt install -y vivaldi-stable');\n    binaryPath = '/usr/bin/vivaldi';\n  } else if (browserName === 'edge') {\n    await execPromise('curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg');\n    await execPromise('sudo mv microsoft.gpg /usr/share/keyrings/microsoft-archive-keyring.gpg');\n    await execPromise(`sudo sh -c 'echo \"deb [arch=${ARCH === 'arm64' ? 'arm64' : 'amd64'} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/edge stable main\" > /etc/apt/sources.list.d/microsoft-edge.list'`);\n    await execPromise('sudo apt update && sudo apt install -y microsoft-edge-stable');\n    binaryPath = '/usr/bin/microsoft-edge';\n  } else if (browserName === 'chromium') {\n    await execPromise('sudo apt update && sudo apt install -y chromium-browser');\n    // Check for Snap installation\n    try {\n      const { stdout } = await execPromise('which chromium');\n      binaryPath = stdout.trim();\n      if (binaryPath.includes('/snap/')) {\n        binaryPath = '/snap/bin/chromium';\n      } else {\n        binaryPath = '/usr/bin/chromium-browser';\n      }\n    } catch {\n      binaryPath = '/usr/bin/chromium-browser';\n    }\n  }\n  return binaryPath;\n}\n\nasync function installOnFedora(browserName) {\n  let binaryPath = '/usr/bin/' + browserName;\n  if (browserName === 'chrome') {\n    await execPromise('sudo dnf config-manager --add-repo https://dl.google.com/linux/chrome/rpm/stable/x86_64');\n    await execPromise('sudo rpm --import https://dl.google.com/linux/linux_signing_key.pub');\n    await execPromise('sudo dnf install -y google-chrome-stable');\n    binaryPath = '/usr/bin/google-chrome';\n  } else if (browserName === 'brave') {\n    await execPromise('sudo dnf config-manager --add-repo https://brave-browser-rpm-release.s3.brave.com/x86_64/');\n    await execPromise('sudo rpm --import https://brave-browser-rpm-release.s3.brave.com/brave-core.asc');\n    await execPromise('sudo dnf install -y brave-browser');\n    binaryPath = '/usr/bin/brave-browser';\n  } else if (browserName === 'vivaldi') {\n    await execPromise('sudo dnf config-manager --add-repo https://repo.vivaldi.com/archive/vivaldi-fedora.repo');\n    await execPromise('sudo dnf install -y vivaldi-stable');\n    binaryPath = '/usr/bin/vivaldi';\n  } else if (browserName === 'edge') {\n    await execPromise('sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc');\n    await execPromise('sudo dnf config-manager --add-repo https://packages.microsoft.com/yumrepos/edge');\n    await execPromise('sudo dnf install -y microsoft-edge-stable');\n    binaryPath = '/usr/bin/microsoft-edge';\n  } else if (browserName === 'chromium') {\n    await execPromise('sudo dnf install -y chromium');\n    binaryPath = '/usr/bin/chromium-browser';\n  }\n  return binaryPath;\n}\n\nasync function getLinuxDistro() {\n  try {\n    const osRelease = await readFile('/etc/os-release', 'utf8');\n    const lines = osRelease.split('\\n');\n    const releaseInfo = {};\n    for (const line of lines) {\n      const [key, value] = line.split('=');\n      if (key && value) {\n        releaseInfo[key] = value.replace(/\"/g, '');\n      }\n    }\n\n    if (releaseInfo.ID === 'fedora' || releaseInfo.ID_LIKE?.includes('fedora')) {\n      return 'fedora';\n    } else if (releaseInfo.ID === 'debian' || releaseInfo.ID === 'ubuntu' || releaseInfo.ID_LIKE?.includes('debian')) {\n      return 'debian';\n    } else {\n      return releaseInfo.ID || 'unknown';\n    }\n  } catch (error) {\n    console.error(`Failed to read /etc/os-release: ${error.message}`);\n    return 'unknown';\n  }\n}\n\n// Helper functions\nasync function downloadBinary(url, outputPath) {\n  const response = await fetch(url, {\n    agent: url.startsWith('https:') ? new https.Agent({ keepAlive: true }) : undefined\n  });\n  if (!response.ok) {\n    throw new Error(`Failed to download ${url}: ${response.statusText}`);\n  }\n  await pipeline(response.body, createWriteStream(outputPath));\n}\n\nfunction getDownloadUrl(browserName, platform, arch) {\n  const urls = {\n    chrome: {\n      win32: { x64: 'https://dl.google.com/chrome/install/ChromeSetup.exe', arm64: 'https://dl.google.com/chrome/install/ChromeSetup.exe' },\n      darwin: { x64: 'https://dl.google.com/chrome/mac/stable/GGRO/googlechrome.dmg', arm64: 'https://dl.google.com/chrome/mac/arm64/googlechrome.dmg' },\n      linux: { x64: 'https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb', arm64: null }\n    },\n    brave: {\n      win32: { x64: 'https://referrals.brave.com/latest/BraveBrowserSetup.exe', arm64: 'https://referrals.brave.com/latest/BraveBrowserSetup.exe' },\n      darwin: { x64: 'https://laptop-updates.brave.com/latest/osx/Brave-Browser.dmg', arm64: 'https://laptop-updates.brave.com/latest/osx-arm64/Brave-Browser.dmg' },\n      linux: { x64: 'https://laptop-updates.brave.com/latest/linux64', arm64: 'https://laptop-updates.brave.com/latest/linux-arm64' }\n    },\n    vivaldi: {\n      win32: { x64: 'https://downloads.vivaldi.com/stable/Vivaldi_Setup.exe', arm64: 'https://downloads.vivaldi.com/stable/Vivaldi_Setup.exe' },\n      darwin: { x64: 'https://downloads.vivaldi.com/stable/Vivaldi.dmg', arm64: 'https://downloads.vivaldi.com/stable/Vivaldi.dmg' },\n      linux: { x64: 'https://downloads.vivaldi.com/stable/vivaldi-stable_amd64.deb', arm64: 'https://downloads.vivaldi.com/stable/vivaldi-stable_arm64.deb' }\n    },\n    edge: {\n      win32: { x64: 'https://go.microsoft.com/fwlink/?linkid=2069148', arm64: 'https://go.microsoft.com/fwlink/?linkid=2069148' },\n      darwin: { x64: 'https://go.microsoft.com/fwlink/?linkid=2069324', arm64: 'https://go.microsoft.com/fwlink/?linkid=2069324' },\n      linux: { x64: 'https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable/microsoft-edge-stable_latest_amd64.deb', arm64: 'https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable/microsoft-edge-stable_latest_arm64.deb' }\n    },\n    chromium: {\n      win32: { x64: 'https://download-chromium.appspot.com/dl/Win_x64?type=snapshots', arm64: 'https://download-chromium.appspot.com/dl/Win_arm64?type=snapshots' },\n      darwin: { x64: 'https://download-chromium.appspot.com/dl/Mac?type=snapshots', arm64: 'https://download-chromium.appspot.com/dl/Mac_Arm?type=snapshots' },\n      linux: { x64: 'https://download-chromium.appspot.com/dl/Linux_x64?type=snapshots', arm64: 'https://download-chromium.appspot.com/dl/Linux_Arm?type=snapshots' }\n    }\n  };\n  return urls[browserName]?.[platform]?.[arch] || null;\n}\n"
  },
  {
    "path": "src/launcher.js",
    "content": "﻿// launcher.js\nimport { spawn } from 'child_process';\nimport { DEBUG } from './common.js'; // Assuming common.js is accessible\n\n/**\n * Launches a browser executable with specified arguments.\n * @param {string} executablePath - Absolute path to the browser executable.\n * @param {string[]} browserArgs - Array of arguments to pass to the browser.\n * @param {object} [options={}] - Options for child_process.spawn.\n * @returns {import('child_process').ChildProcess | null} The spawned browser process or null on error.\n */\nfunction launch(executablePath, browserArgs = [], options = {}) {\n  if (!executablePath) {\n    console.error('launcher.js: Executable path is required.');\n    return null;\n  }\n\n  DEBUG.verbose && console.log(`launcher.js: Spawning '${executablePath}' with args:`, browserArgs);\n\n  try {\n    const defaultSpawnOptions = {\n      detached: process.platform !== 'win32', // Detach by default on non-Windows for independent exit\n      stdio: ['ignore', 'pipe', 'pipe'], \n    };\n\n    const spawnOptions = { ...defaultSpawnOptions, ...options };\n\n    const browserProcess = spawn(executablePath, browserArgs, spawnOptions);\n\n    browserProcess.on('error', (err) => {\n      console.error(`launcher.js: Failed to start browser process for ${executablePath}: ${err.message}`);\n    });\n\n    if (DEBUG.verboseBrowser) {\n      const browserName = executablePath.split(/[/\\\\]/).pop();\n      browserProcess.stdout.on('data', (data) => {\n        DEBUG.verbose && process.stdout.write(`[BROWSER STDOUT - ${browserName}]: ${data}`);\n      });\n      browserProcess.stderr.on('data', (data) => {\n        DEBUG.verbose && process.stderr.write(`[BROWSER STDERR - ${browserName}]: ${data}`);\n      });\n    }\n    \n    // If detached, unref() allows the parent to exit independently.\n    // This is often desired so closing the terminal doesn't kill the browser launched by the script.\n    if (spawnOptions.detached) {\n      browserProcess.unref();\n    }\n\n    return browserProcess;\n  } catch (error) {\n    console.error(`launcher.js: Error spawning browser ${executablePath}: ${error.message}`);\n    DEBUG.verbose && console.error(error);\n    return null;\n  }\n}\n\nexport default {\n  launch,\n};\n"
  },
  {
    "path": "src/libraryServer.js",
    "content": "import sea from 'node:sea';\nimport http from 'http';\nimport https from 'https';\nimport fs from 'fs';\nimport os from 'os'; // Included as it was in your original list, though not directly used in this server logic\nimport path from 'path';\n\nimport express from 'express';\n\nimport args from './args.js';\nimport {\n  GO_SECURE,\n  CERT_PATH,\n  DEBUG,\n  MAX_REAL_URL_LENGTH,\n  MAX_HEAD, MAX_HIGHLIGHTABLE_LENGTH,\n  say, sleep, APP_ROOT,\n  RichError\n} from './common.js';\nimport {startCrawl, Archivist} from './archivist.js';\nimport {trilight, highlight} from './highlighter.js'; // trilight is imported but its usage was commented out in original\n\nconst SITE_PATH = path.resolve(APP_ROOT, '..', 'public'); // Serves static files like style.css from here\n\nconst SearchCache = new Map();\n\nconst app = express();\n\nlet running = false;\nlet Server, upAt, port;\n\nconst LibraryServer = {\n  start, stop\n}\n\nconst secure_options = {};\nconst protocol = GO_SECURE ? https : http;\n\nexport default LibraryServer;\n\n// --- PageLayout Helper ---\n// (Incorporates changes for new default page and /settings path)\nfunction PageLayout({ title, content, currentNav, layoutType = 'default' }) {\n  return `\n    <!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>${title} - DownloadNet</title>\n      <link rel=\"stylesheet\" href=\"/style.css\">\n      <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💾</text></svg>\">\n    </head>\n    <body>\n      <div class=\"container\">\n        <header class=\"site-header\">\n          <h1><a href=\"/\">DownloadNet</a></h1>\n          <nav class=\"main-nav\">\n            <ul>\n              <li><a href=\"/archive_index.html\" class=\"${currentNav === 'index' ? 'active' : ''}\">View Index</a></li>\n              <li><a href=\"/search\" class=\"${currentNav === 'search' ? 'active' : ''}\">Search Archive</a></li>\n              <li><a href=\"/settings\" class=\"${currentNav === 'settings' ? 'active' : ''}\">Crawl & Settings</a></li>\n            </ul>\n          </nav>\n        </header>\n        <main class=\"${layoutType === 'sidebar' ? 'page-with-sidebar' : ''}\">\n          ${content}\n        </main>\n        <footer class=\"site-footer\">\n          <p>© ${new Date().getFullYear()} DownloadNet. Server up since: ${upAt ? upAt.toLocaleString() : 'N/A'}.</p>\n        </footer>\n      </div>\n    </body>\n    </html>\n  `;\n}\n\n\nasync function start({server_port}) {\n  if ( running ) {\n    DEBUG.verboseSlow && console.warn(`Attempting to start server when it is not closed. Exiting start()...`);\n    return;\n  }\n  running = true;\n  \n  if (GO_SECURE) {\n    try {\n      const certPathDir = CERT_PATH(); // Call once\n      const sec = {\n        key: fs.readFileSync(path.resolve(certPathDir, 'privkey.pem')),\n        cert: fs.readFileSync(path.resolve(certPathDir, 'fullchain.pem')),\n        ca: fs.existsSync(path.resolve(certPathDir, 'chain.pem')) ?\n            fs.readFileSync(path.resolve(certPathDir, 'chain.pem'))\n          :\n            undefined\n      };\n      DEBUG.debugSec && console.log({sec});\n      Object.assign(secure_options, sec);\n    } catch(e) {\n      console.warn(`GO_SECURE is true, but SSL certs not found or unreadable at ${CERT_PATH()}. Will attempt to use insecure HTTP. Error: ${e.message}`);\n    }\n  }\n\n  try {\n    port = server_port;\n    addHandlers();\n    const useSecureServer = GO_SECURE && secure_options.key && secure_options.cert;\n    const selectedProtocol = useSecureServer ? https : http;\n\n    const server = selectedProtocol.createServer.apply(\n        selectedProtocol, \n        useSecureServer ? [secure_options, app] : [app]\n    );\n\n    Server = server.listen(Number(port), err => {\n      if ( err ) { \n        running = false;\n        throw err;\n      } \n      upAt = new Date();\n      say({server_up:{\n        upAt,\n        port,\n        protocol: useSecureServer ? 'https' : 'http',\n        ...(DEBUG.verboseSlow ? {\n          static_site_path: SITE_PATH,\n          app_root: APP_ROOT,\n        } : {})\n      }});\n    });\n  } catch(e) {\n    running = false;\n    DEBUG.verboseSlow && console.error(`Error starting server`, e);\n    process.exit(1);\n  }\n}\n\n// --- addHandlers function with routing changes ---\nfunction addHandlers() {\n  app.use(express.urlencoded({extended:true, limit: '50mb'}));\n\n  if ( args.library_path() ) {\n    app.use(\"/library\", express.static(args.library_path()))\n  }\n\n  // --- Root path now redirects to View Index ---\n  app.get('/', (req, res) => {\n    res.redirect('/archive_index.html');\n  });\n\n  // --- New path for Crawl & Settings page ---\n  app.get('/settings', (req, res) => {\n    // MainApplicationView now uses currentNav: 'settings'\n    res.send(MainApplicationView());\n  });\n  \n  app.get(['/search', '/search.json'], async (req, res) => {\n    await Archivist.isReady();\n    let {query:oquery} = req.query;\n    let page = req.query.page;\n\n    if (!oquery || typeof oquery !== 'string' || oquery.trim() === \"\") {\n      // SearchResultView uses currentNav: 'search'\n      return res.send(SearchResultView({results:[], query:'', HL:new Map, page:1, hasMore: false}));\n    }\n    oquery = oquery.trim();\n\n    if ( ! page || ! Number.isInteger(parseInt(page)) || parseInt(page) < 1 ) {\n      page = 1;\n    } else {\n      page = parseInt(page);\n    }\n\n    let resultIds, query, HL;\n    if ( SearchCache.has(oquery) ) {\n      ({query, resultIds, HL} = SearchCache.get(oquery));\n    } else {\n      ({query, results:resultIds, HL} = await Archivist.search(oquery));\n      SearchCache.set(oquery, {query, resultIds, HL});\n    }\n\n    const startIdx = (page-1)*args.results_per_page;\n    const paginatedResultIds = resultIds.slice(startIdx, startIdx + args.results_per_page);\n    const results = paginatedResultIds.map(docId => Archivist.getDetails(docId));\n    const hasMore = resultIds.length > startIdx + args.results_per_page;\n\n    if ( req.path.endsWith('.json') ) {\n      res.json({ results, query, page, hasMore });\n    } else {\n      results.forEach(r => {\n        if (r && r.content) {\n          r.snippet = '... ' + highlight(query, r.content, {\n              maxLength: MAX_HIGHLIGHTABLE_LENGTH, \n              around: '<mark>',\n              before: '</mark>'\n            })\n            .sort(({fragment:{offset:a}}, {fragment:{offset:b}}) => a-b)\n            .map(hl => hl.fragment.text)\n            .join(' ... ');\n        } else {\n          r.snippet = 'Content not available for snippet.';\n        }\n      });\n      // SearchResultView uses currentNav: 'search'\n      res.send(SearchResultView({results, query, HL, page, hasMore}));\n    }\n  });\n\n  app.get('/mode', async (req, res) => {\n    res.send(Archivist.getMode());\n  });\n\n  app.get('/archive_index.html', async (req, res) => {\n    Archivist.saveIndex();\n    const index = Archivist.getIndex();\n    // IndexView uses currentNav: 'index'\n    res.send(IndexView(index, {edit:false}));\n  });\n\n  app.get('/edit_index.html', async (req, res) => {\n    Archivist.saveIndex();\n    const index = Archivist.getIndex();\n    // IndexView uses currentNav: 'index'\n    res.send(IndexView(index, {edit:true}));\n  });\n\n  app.post('/edit_index.html', async (req, res) => {\n    const {url_to_delete} = req.body;\n    if (url_to_delete && typeof url_to_delete === 'string') {\n        await Archivist.deleteFromIndexAndSearch(url_to_delete);\n    }\n    res.redirect('/edit_index.html');\n  });\n\n  app.post('/mode', async (req, res) => {\n    const {mode} = req.body;\n    if (mode && typeof mode === 'string' && ['record', 'replay', 'live'].includes(mode)) {\n        Archivist.changeMode(mode);\n    }\n    // Redirect to /settings page with hash\n    res.redirect('/settings#mode-settings');\n  });\n\n  app.get('/base_path', async (req, res) => {\n    res.send(args.getBasePath());\n  });\n\n  app.post('/base_path', async (req, res) => {\n    const {base_path} = req.body;\n    if (typeof base_path !== 'string') {\n        // Redirect to /settings page with error and hash\n        return res.redirect(`/settings?error=${encodeURIComponent('Invalid base_path provided.')}#base-path-settings`);\n    }\n    const change = args.updateBasePath(base_path, {before: [\n      () => Archivist.beforePathChanged(base_path)\n    ]});\n\n    if ( change ) {\n      await Archivist.afterPathChanged();\n      if (Server) {\n        Server.close(async () => {\n          running = false;\n          console.log(`Server closed for base_path change.`);\n          await sleep(50);\n          start({server_port:port});\n          console.log(`Server restarting with new base_path.`);\n        });\n      } else {\n          console.log(`Server was not running. Attempting to start with new base_path.`);\n          await sleep(50);\n          start({server_port:port});\n      }\n      // Redirect to /settings page with hash\n      res.redirect('/settings#base-path-settings');\n    } else {\n      // Redirect to /settings page with hash\n      res.redirect('/settings#base-path-settings');\n    }\n  });\n\n  app.post('/crawl', async (req, res) => {\n    try {\n      let {\n        links, timeout, depth, saveToFile, \n        maxPageCrawlTime, minPageCrawlTime, batchSize,\n        program,\n      } = req.body;\n\n      const oTimeout = timeout;\n      timeout = Math.round(parseFloat(timeout)*1000);\n      depth = Math.round(parseInt(depth));\n      batchSize = Math.round(parseInt(batchSize));\n      saveToFile = !!(saveToFile && saveToFile !== 'false');\n      minPageCrawlTime = Math.round(parseInt(minPageCrawlTime)*1000);\n      maxPageCrawlTime = Math.round(parseInt(maxPageCrawlTime)*1000);\n\n      if ( Number.isNaN(timeout) || timeout < 0 ||\n           Number.isNaN(depth) || depth < 0 ||\n           typeof links !== 'string' ) {\n        console.warn({invalid_crawl_params:{timeout,depth,links}});\n        throw new RichError({\n          status: 400, \n          message: 'Invalid parameters: timeout, depth or links must be valid and non-negative.'\n        });\n      }\n\n      const urls = links.split(/[\\n\\s\\r]+/g)\n        .map(u => u.trim())\n        .filter(u => {\n          if (u.length === 0 || u.length > MAX_REAL_URL_LENGTH) return false;\n          try {\n            new URL(u);\n            return true;\n          } catch { return false; }\n        }).map(url => ({url,depth:1}));\n\n      if (urls.length === 0) {\n        throw new RichError({\n            status: 400,\n            message: 'No valid URLs provided for crawling.'\n        });\n      }\n      \n      console.log(`Starting crawl from ${urls.length} URLs, waiting ${oTimeout} seconds for each to load, and continuing to a depth of ${depth} clicks...`); \n      startCrawl({\n        urls, timeout, depth, saveToFile, batchSize, minPageCrawlTime, maxPageCrawlTime, program,\n      }).catch(crawlError => {\n          console.error(\"Error during background crawl process:\", crawlError);\n      });\n      // Redirect to /settings page with hash\n      res.redirect('/settings#crawl-form');\n    } catch(e) {\n      let errorMessage = 'An unexpected error occurred during crawl setup.';\n      if ( e instanceof RichError ) { \n        console.warn(e);\n        try {\n            const parsedError = JSON.parse(e.message);\n            errorMessage = parsedError.message || errorMessage;\n        } catch { /* Use default error message */ }\n      } else {\n        console.warn(e);\n      }\n      // Redirect to /settings page with error and hash\n      return res.redirect(`/settings?error=${encodeURIComponent(errorMessage)}#crawl-form`);\n    }\n  });\n\n  app.get(/^\\/.*/, async (req, res) => {\n    const requestedPath = (req?.params?.path || req.path).slice(1);\n    DEBUG.verbose && console.log({requestedPath});\n    const file = requestedPath === '' ? 'index.html' : requestedPath; // Should be archive_index.html due to root redirect\n    \n    if (file === 'style.css') {\n      if ( sea.isSea() ) {\n        try {\n            const asset = await sea.getAsset('style.css');\n            res.type('css').send(Buffer.from(asset));\n            return;\n        } catch (e) {\n            console.warn(`Failed to load style.css from SEA:`, e);\n        }\n      }\n    }\n\n    let asset;\n    if ( sea.isSea() ) {\n      try {\n        asset = await sea.getAsset(file);\n      } catch(e) {\n        if (!file.endsWith('.html')) {\n            try {\n                asset = await sea.getAsset(file + '.html');\n            } catch (e2) { /* console.warn for debugging */ }\n        } else { /* console.warn for debugging */ }\n      }\n    } else {\n      asset = fs.readFileSync(path.resolve(SITE_PATH, file));\n    }\n\n    if ( asset ) {\n      const type = path.extname(file).slice(1) || 'html';\n      res.type(type);\n      let data = Buffer.from(asset);\n      if (['html', 'js', 'css', 'json', 'txt', 'xml', 'svg'].includes(type)) {\n        data = data.toString('utf8');\n      } \n      res.send(data);\n    } else {\n      // If root path ('') falls through, it means /archive_index.html wasn't found in SEA\n      // or another specific handler like /settings wasn't found.\n      if (requestedPath === '' || file === 'archive_index.html' || file === 'settings') {\n          console.error(`Error: SEA handler reached for a primary path (${file}), but it should have been handled or found.`);\n          res.status(404).send(`Primary application asset not found in SEA: ${file}`);\n      } else {\n          res.status(404).send(`Asset not found in SEA: ${file}`);\n      }\n    }\n  });\n}\n\nasync function stop() {\n  let resolve;\n  const pr = new Promise(res => resolve = res);\n  console.log(`Closing library server...`);\n  if ( Server ) {\n    Server.close((err) => {\n      if (err) console.error(\"Error closing library server:\", err);\n      else console.log(`Library server closed.`);\n      running = false; Server = null; resolve();\n    });\n  } else {\n    console.log(`Library server was not running or already closed.`);\n    running = false; resolve();\n  }\n  return pr;\n}\n\n// --- MainApplicationView (for /settings page) ---\nfunction MainApplicationView() {\n  const currentBasePath = args.getBasePath();\n  const currentMode = Archivist.getMode();\n\n  const content = `\n    <aside class=\"page-sidebar\">\n      <h3>Settings Sections</h3>\n      <nav class=\"sidebar-nav\" aria-label=\"Settings sections\">\n        <ul>\n          <li><a href=\"#crawl-form\" data-section=\"crawl-form\">New Crawl</a></li>\n          <li><a href=\"#mode-settings\" data-section=\"mode-settings\">Archivist Mode</a></li>\n          <li><a href=\"#base-path-settings\" data-section=\"base-path-settings\">Library Base Path</a></li>\n        </ul>\n      </nav>\n    </aside>\n\n    <div class=\"main-content-area\">\n      <section id=\"crawl-form\" aria-labelledby=\"crawl-form-legend\" class=\"active-section\">\n        <form method=\"POST\" action=\"/crawl\">\n          <fieldset>\n            <legend id=\"crawl-form-legend\">Start a New Crawl</legend>\n            <div class=\"form-group\">\n              <label for=\"links\">Enter URLs (one per line):</label>\n              <textarea id=\"links\" name=\"links\" rows=\"5\" required placeholder=\"https://example.com\\nhttps://another.example.org\"></textarea>\n            </div>\n            <div class=\"form-group\">\n              <label for=\"depth\">Crawl Depth:</label>\n              <input type=\"number\" id=\"depth\" name=\"depth\" value=\"1\" min=\"0\" required>\n              <small>0 for current page only, 1 for one level of links, etc.</small>\n            </div>\n            <div class=\"form-group\">\n              <label for=\"timeout\">Page Load Timeout (seconds):</label>\n              <input type=\"number\" id=\"timeout\" name=\"timeout\" value=\"30\" min=\"1\" step=\"0.1\" required>\n            </div>\n            <div class=\"form-group\">\n              <label for=\"minPageCrawlTime\">Min Page Crawl Time (seconds):</label>\n              <input type=\"number\" id=\"minPageCrawlTime\" name=\"minPageCrawlTime\" value=\"1\" min=\"0\" step=\"1\" required>\n            </div>\n            <div class=\"form-group\">\n              <label for=\"maxPageCrawlTime\">Max Page Crawl Time (seconds):</label>\n              <input type=\"number\" id=\"maxPageCrawlTime\" name=\"maxPageCrawlTime\" value=\"60\" min=\"1\" step=\"1\" required>\n            </div>\n            <div class=\"form-group\">\n              <label for=\"batchSize\">Batch Size (pages per batch):</label>\n              <input type=\"number\" id=\"batchSize\" name=\"batchSize\" value=\"5\" min=\"1\" required>\n            </div>\n            <div class=\"form-group\">\n              <label for=\"program\">Crawl Program (optional):</label>\n              <input type=\"text\" id=\"program\" name=\"program\" placeholder=\"e.g., my_custom_script.js\">\n            </div>\n            <div class=\"form-group\" style=\"display: flex; align-items: center;\">\n              <input type=\"checkbox\" id=\"saveToFile\" name=\"saveToFile\" value=\"true\" checked style=\"width: auto; margin-right: var(--spacing-sm);\">\n              <label for=\"saveToFile\" style=\"display: inline-block; margin-bottom: 0; font-weight: normal;\">Save to File (MHTML)</label>\n            </div>\n            <button type=\"submit\">Start Crawl</button>\n          </fieldset>\n        </form>\n      </section>\n\n      <section id=\"mode-settings\" aria-labelledby=\"mode-settings-legend\">\n        <form method=\"POST\" action=\"/mode\">\n          <fieldset>\n            <legend id=\"mode-settings-legend\">Archivist Mode</legend>\n            <div class=\"form-group\">\n              <label for=\"mode\">Current Mode: <strong>${currentMode}</strong>. Select new mode:</label>\n              <select id=\"mode\" name=\"mode\">\n                <option value=\"record\" ${currentMode === 'record' ? 'selected' : ''}>Record Mode</option>\n                <option value=\"replay\" ${currentMode === 'replay' ? 'selected' : ''}>Replay Mode</option>\n                <option value=\"live\" ${currentMode === 'live' ? 'selected' : ''}>Live Mode</option>\n              </select>\n            </div>\n            <button type=\"submit\">Set Mode</button>\n          </fieldset>\n        </form>\n      </section>\n\n      <section id=\"base-path-settings\" aria-labelledby=\"base-path-settings-legend\">\n        <form method=\"POST\" action=\"/base_path\">\n          <fieldset>\n            <legend id=\"base-path-settings-legend\">Library Base Path</legend>\n            <div class=\"form-group\">\n              <label for=\"base_path\">Current Path: <code>${currentBasePath}</code>. Enter new path:</label>\n              <input type=\"text\" id=\"base_path\" name=\"base_path\" value=\"${currentBasePath}\" required>\n              <small>Set the root directory for storing archives. Server will restart if changed.</small>\n            </div>\n            <button type=\"submit\">Update Base Path</button>\n          </fieldset>\n        </form>\n      </section>\n    </div> \n    <script>\n      document.addEventListener('DOMContentLoaded', () => {\n        const sidebarLinks = document.querySelectorAll('.sidebar-nav a[data-section]');\n        const contentSections = document.querySelectorAll('.main-content-area > section');\n        \n        function setActiveSection(sectionId) {\n          let sectionFound = false;\n          sidebarLinks.forEach(link => {\n            link.classList.toggle('active', link.dataset.section === sectionId);\n          });\n          contentSections.forEach(section => {\n            const isActive = section.id === sectionId;\n            section.classList.toggle('active-section', isActive);\n            if (isActive) sectionFound = true;\n          });\n          if (!sectionFound && contentSections.length > 0) {\n             contentSections[0].classList.add('active-section');\n             if(sidebarLinks.length > 0) sidebarLinks[0].classList.add('active');\n          }\n        }\n\n        sidebarLinks.forEach(link => {\n          link.addEventListener('click', (event) => {\n            const sectionId = event.currentTarget.dataset.section;\n            setActiveSection(sectionId);\n            if (history.pushState) {\n                 history.pushState(null, null, '#' + sectionId);\n            } else {\n                 window.location.hash = sectionId;\n            }\n          });\n        });\n\n        const urlParams = new URLSearchParams(window.location.search);\n        const generalError = urlParams.get('error');\n        let initialSectionId = window.location.hash.substring(1);\n\n        if (generalError && initialSectionId) {\n          const targetSection = document.getElementById(initialSectionId);\n          if (targetSection) {\n            const errorDiv = document.createElement('div');\n            errorDiv.className = 'form-error-message';\n            errorDiv.textContent = 'Error: ' + decodeURIComponent(generalError);\n            const formInErrorSection = targetSection.querySelector('form');\n            if (formInErrorSection) {\n                formInErrorSection.insertBefore(errorDiv, formInErrorSection.firstChild);\n            } else {\n                targetSection.insertBefore(errorDiv, targetSection.firstChild);\n            }\n          }\n        }\n        \n        if (!initialSectionId && sidebarLinks.length > 0) {\n            initialSectionId = sidebarLinks[0].dataset.section;\n        }\n        setActiveSection(initialSectionId);\n\n        if (window.location.hash) {\n          setTimeout(() => {\n            try {\n              const targetElement = document.querySelector(window.location.hash);\n              if (targetElement) {\n                targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });\n              }\n            } catch (e) { console.warn('Invalid hash for scrolling:', window.location.hash); }\n          }, 100);\n        }\n      });\n    </script>\n  `;\n  // Use layoutType: 'sidebar' and currentNav: 'settings' for this specific view\n  return PageLayout({ title: 'Crawl & Settings', content, currentNav: 'settings', layoutType: 'sidebar' });\n}\n\n\n// --- IndexView ---\nfunction IndexView(urls, {edit = false} = {}) {\n  const pageTitle = edit ? 'Edit Your HTML Library Index' : 'Your HTML Library Index';\n  const content = `\n    <div style=\"display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--spacing-md); margin-bottom: var(--spacing-lg);\">\n      <h2 class=\"page-title\" style=\"margin-bottom: 0;\">${pageTitle}</h2>\n      <div class=\"edit-toggle-section\" style=\"margin-bottom: 0;\">\n        <form method=\"GET\" action=\"${edit ? '/archive_index.html' : '/edit_index.html'}\">\n          <button type=\"submit\" class=\"button secondary\">\n            ${edit ? '✓ View Index' : '✎ Edit Index'}\n          </button>\n        </form>\n      </div>\n    </div>\n    \n    <form method=\"GET\" action=\"/search\" class=\"mb-0\">\n      <fieldset>\n        <legend>Search Your Archive</legend>\n        <div class=\"input-group\">\n          <input autofocus type=\"search\" name=\"query\" placeholder=\"Enter search terms...\">\n          <button type=\"submit\">Search</button>\n        </div>\n      </fieldset>\n    </form>\n\n    ${urls.length === 0 ? '<p style=\"margin-top: var(--spacing-lg); text-align: center;\">Your index is currently empty. Start a crawl to add items!</p>' : ''}\n    <ul class=\"item-list\">\n    ${\n      urls.map(([url,{title, id}]) => `\n        <li>\n          ${ DEBUG ? `<small class=\"debug-info\">ID: ${id}</small>` : ''} \n          <h3 class=\"item-title\"><a target=\"_blank\" href=\"${url}\" rel=\"noopener noreferrer\">${(title || url).slice(0, MAX_HEAD)}</a></h3>\n          <small class=\"item-url\"><a target=\"_blank\" href=\"${url}\" rel=\"noopener noreferrer\">${url.slice(0, MAX_HEAD)}</a></small>\n          ${ edit ? `\n          <div class=\"item-actions\">\n            <form class=\"delete-form\" method=\"POST\" action=\"/edit_index.html\">\n              <input name=\"url_to_delete\" type=\"hidden\" value=\"${url}\">\n              <button type=\"button\" class=\"delete-button\" title=\"Delete this item\" onclick=\"confirmDelete(event);\">\n                🗑️ Delete\n              </button>\n            </form>\n          </div>\n          ` : ''}\n        </li>\n      `).join('\\n')\n    }\n    </ul>\n    ${ edit ? `\n    <script>\n      const sleep = ms => new Promise(res => setTimeout(res, ms));\n      async function confirmDelete(event) {\n        const button = event.currentTarget;\n        const form = button.closest('form');\n        const listItem = button.closest('li');\n        const linkElement = listItem.querySelector('.item-title a');\n        \n        button.disabled = true;\n        const originalTextDecoration = linkElement.style.textDecoration;\n        linkElement.classList.add('strikethrough');\n        \n        let {host} = new URL(form.url_to_delete.value);\n        host = host.replace(/^www\\\\./i, '');\n        \n        await sleep(100);\n        \n        const reallyDelete = confirm(\n          \\`Are you sure you want to delete this item from the index and search?\\\\n\\\\n  \\${host} \\n\\\\nThis action cannot be undone easily.\\`\n        );\n        \n        if (reallyDelete) {\n          form.submit();\n        } else {\n          linkElement.classList.remove('strikethrough');\n          linkElement.style.textDecoration = originalTextDecoration;\n          button.disabled = false;\n        }\n      }\n    </script>\n    ` : ''}\n  `;\n  // Uses default layout and currentNav: 'index'\n  return PageLayout({ title: pageTitle, content, currentNav: 'index' });\n}\n\n// --- SearchResultView ---\nfunction SearchResultView({results, query, HL, page, hasMore = false}) {\n  const pageTitle = query ? `Search Results for \"${query}\"` : \"Search Archive\";\n  const content = `\n    <h2 class=\"page-title\">${pageTitle}</h2>\n    <form method=\"GET\" action=\"/search\" class=\"mb-0\">\n      <fieldset>\n        <legend>Search Your Archive</legend>\n        <div class=\"input-group\">\n          <input autofocus type=\"search\" name=\"query\" placeholder=\"Enter search terms...\" value=\"${query || ''}\">\n          <button type=\"submit\">Search</button>\n        </div>\n      </fieldset>\n    </form>\n\n    ${query && results.length === 0 ? '<p style=\"margin-top: var(--spacing-lg); text-align: center;\">No results found for your query.</p>' : ''}\n    ${!query && results.length === 0 ? '<p style=\"margin-top: var(--spacing-lg); text-align: center;\">Please enter a search term above to begin.</p>' : ''}\n\n\n    ${results.length > 0 ? `\n    <p style=\"margin-top: var(--spacing-lg);\">Showing results for: <strong>${query}</strong></p>\n    <ol class=\"item-list\" start=\"${(page-1)*args.results_per_page+1}\">\n    ${\n      results.map(({snippet, url, title, id}) => `\n        <li>\n          ${DEBUG ? `<small class=\"debug-info\">ID: ${id}</small>` : ''}\n          <h3 class=\"item-title\">\n            <a target=\"_blank\" href=\"${url}\" rel=\"noopener noreferrer\">${\n              HL.get(id)?.title || (title || url || '').slice(0, MAX_HEAD)\n            }</a>\n          </h3>\n          <small class=\"item-url\">\n            <a target=\"_blank\" href=\"${url}\" rel=\"noopener noreferrer\">${\n              HL.get(id)?.url || (url || '').slice(0, MAX_HEAD)\n            }</a>\n          </small>\n          ${snippet ? `<p class=\"item-snippet\">${snippet}</p>` : ''}\n        </li>\n      `).join('\\n')\n    }\n    </ol>\n    ` : ''}\n\n    ${(results.length > 0 || page > 1) ? `\n    <nav class=\"pagination\" aria-label=\"Search results pagination\">\n      ${page > 1 ? `\n      <a href=\"/search?query=${encodeURIComponent(query)}&page=${encodeURIComponent(page-1)}\">\n        « Previous\n      </a>` : `<span class=\"disabled\">« Previous</span>`}\n      \n      <span aria-current=\"page\">Page ${page}</span>\n      \n      ${hasMore ? `\n      <a href=\"/search?query=${encodeURIComponent(query)}&page=${encodeURIComponent(page+1)}\">\n        Next »\n      </a>` : `<span class=\"disabled\">Next »</span>`}\n    </nav>\n    ` : ''}\n  `;\n  // Uses default layout and currentNav: 'search'\n  return PageLayout({ title: `Search: ${query || 'Archive'}`, content, currentNav: 'search' });\n}\n"
  },
  {
    "path": "src/protocol.js",
    "content": "import Ws from 'ws';\nimport {sleep, untilTrue, SHOW_FETCH, DEBUG, ERROR_CODE_SAFE_TO_IGNORE} from './common.js';\n\nconst ROOT_SESSION = \"browser\";\nconst MESSAGES = new Map();\n\nconst RANDOM_LOCAL = () => [\n  '127.0.0.1',\n  '[::1]',\n  'localhost',\n  '127.0.0.1',\n  '[::1]',\n  'localhost'\n][Math.floor(Math.random()*6)];\n\nexport async function connect({port:port = 9222} = {}) {\n  let webSocketDebuggerUrl, socket;\n  let url;\n  try {\n    await untilTrue(async () => {\n      let result = false;\n      try {\n        url = `http://${RANDOM_LOCAL()}:${port}/json/version`;\n        DEBUG.verbose && console.log(`Trying browser at ${url}...`, url);\n        const {webSocketDebuggerUrl} = await Promise.race([\n          fetch(url).then(r => r.json()),\n          (async () => {\n            await sleep(2500);\n            throw new Error(`Connect took too long.`)\n          })(),\n        ]);\n        if ( webSocketDebuggerUrl ) {\n          result = true;\n        }\n      } catch(e) {\n        DEBUG.verbose && console.error('Error while checking browser', e);\n      } finally {\n        return result; \n      }\n    });\n    ({webSocketDebuggerUrl} = await fetch(url).then(r => r.json()));\n    let isOpen = false;\n    socket = new Ws(webSocketDebuggerUrl);\n    socket.on('open', () => { isOpen = true });\n    await untilTrue(() => isOpen);\n    DEBUG.verbose && console.log(`Connected to browser`);\n  } catch(e) {\n    console.log(\"Error communicating with browser\", e);\n    process.exit(1);\n  }\n\n  const Resolvers = {};\n  const Handlers = {};\n  socket.on('message', handle);\n  let id = 0;\n\n  let resolve, reject;\n  const promise = new Promise((res, rej) => (resolve = res, reject = rej));\n\n  switch(socket.readyState) {\n    case Ws.CONNECTING:\n      socket.on('open', () => resolve()); break;\n    case Ws.OPEN:\n      resolve(); break;\n    case Ws.CLOSED:\n    case Ws.CLOSING:\n      reject(); break;\n  }\n\n  await promise;\n\n  return {\n    send,\n    on, ons, ona,\n    close\n  };\n  \n  async function send(method, params = {}, sessionId) {\n    const message = {\n      method, params, sessionId, \n      id: ++id\n    };\n    if ( ! sessionId ) {\n      delete message[sessionId];\n    }\n    const key = `${sessionId||ROOT_SESSION}:${message.id}`;\n    let resolve;\n    const promise = new Promise(res => resolve = res);\n    Resolvers[key] = resolve;\n    const outGoing = JSON.stringify(message);\n    MESSAGES.set(key, outGoing);\n    socket.send(outGoing);\n    DEBUG.verboseSlow && (SHOW_FETCH || !method.startsWith('Fetch')) && console.log(\"Sent\", message);\n    return promise;\n  }\n\n  async function handle(message) {\n    if ( typeof message !== \"string\" ) {\n      try {\n        message += '';\n      } catch(e) {\n        message = message.toString();\n      }\n    }\n    const stringMessage = message;\n    message = JSON.parse(message);\n    if ( message.error ) {\n      const showError = DEBUG.protocol || !ERROR_CODE_SAFE_TO_IGNORE.has(message.error.code);\n      if ( showError ) {\n        DEBUG.protocol && console.warn(message);\n      }\n    }\n    const {sessionId} = message;\n    const {method} = message;\n    const {id, result} = message;\n\n    if ( id ) {\n      const key = `${sessionId||ROOT_SESSION}:${id}`;\n      const resolve = Resolvers[key];\n      if ( ! resolve ) {\n        DEBUG.protocol && console.warn(`No resolver for key`, key, stringMessage.slice(0,140));\n      } else {\n        Resolvers[key] = undefined;\n        try {\n          await resolve(result);\n        } catch(e) {\n          console.warn(`Resolver failed`, e, key, stringMessage.slice(0,140), resolve);\n        }\n      }\n      if ( DEBUG ) {\n        if ( message.error ) {\n          const showError = DEBUG || !ERROR_CODE_SAFE_TO_IGNORE.has(message.error.code);\n          if ( showError ) {\n            const originalMessage = MESSAGES.get(key);\n            DEBUG.protocol && console.warn({originalMessage});\n          }\n        }\n      }\n      MESSAGES.delete(key);\n    } else if ( method ) {\n      const listeners = Handlers[method];\n      if ( Array.isArray(listeners) ) {\n        for( const func of listeners ) {\n          try {\n            func({message, sessionId});\n          } catch(e) {\n            console.warn(`Listener failed`, method, e, func.toString().slice(0,140), stringMessage.slice(0,140));\n          }\n        }\n      }\n    } else {\n      console.warn(`Unknown message on socket`, message);\n    }\n  }\n\n  function on(method, handler) {\n    let listeners = Handlers[method]; \n    if ( ! listeners ) {\n      Handlers[method] = listeners = [];\n    }\n    listeners.push(wrap(handler));\n  }\n\n  function ons(method, handler) {\n    let listeners = Handlers[method]; \n    if ( ! listeners ) {\n      Handlers[method] = listeners = [];\n    }\n    listeners.push(handler);\n  }\n\n  function ona(method, handler, sessionId) {\n    let listeners = Handlers[method]; \n    if ( ! listeners ) {\n      Handlers[method] = listeners = [];\n    }\n    listeners.push(({message}) => {\n      if ( message.sessionId === sessionId ) {\n        handler(message.params);\n      } else {\n        console.log(`No such`, {method, handler, sessionId, message});\n      }\n    });\n  }\n\n  function close() {\n    socket.close();\n  }\n\n  function wrap(fn) {\n    return ({message}) => fn(message.params)\n  }\n}\n"
  },
  {
    "path": "src/root.cjs",
    "content": "const path = require('path');\nconst url = require('url');\n\nconst file = __filename;\nconst dir = path.dirname(file);\nconst APP_ROOT = dir;\n\n//console.log({APP_ROOT});\n\nmodule.exports = {\n  APP_ROOT,\n  dir,\n  file\n}\n\n"
  },
  {
    "path": "src/root.js",
    "content": "import path from 'path';\nimport url from 'url';\n\nlet mod;\nlet esm = false;\n\ntry {\n  const [a, b] = [__dirname, __filename];\n} catch(e) {\n  esm = true;\n}\n\nif ( ! esm ) {\n  mod = require('./root.cjs');\n} else {\n  const file = url.fileURLToPath(import.meta.url);\n  const dir = path.dirname(file);\n  mod = {\n    dir,\n    file,\n    APP_ROOT: dir\n  };\n}\n\n//console.log({root});\n\nexport const root = mod;\n\n"
  },
  {
    "path": "stampers/macos-new.sh",
    "content": "#!/bin/bash\n\n# macOS Single Executable Application (SEA) Stamper, Signer, and Conditional Notarizer for DownloadNet\n\nset -e\n# set -x \n\n# --- Configuration & Variables ---\nDEFAULT_NODE_VERSION=\"22\"\nMACOS_APP_BUNDLE_ID=\"com.DOSAYGO.DownloadNet\" # Your registered Bundle ID\nENTITLEMENTS_FILE_PATH=\"scripts/downloadnet-entitlements.xml\" \nNOTARIZE_SCRIPT_PATH=\"./stampers/notarize_macos.sh\" # Path to your notarization script\n\n# --- NEW: Check for Notarization Environment Variables ---\nCAN_ATTEMPT_NOTARIZATION=true\necho \"INFO: Checking for notarization prerequisites...\" >&2\nif [ -z \"$API_KEY_ID\" ]; then\n  echo \"WARNING: Environment variable API_KEY_ID is not set. Notarization will be skipped.\" >&2\n  CAN_ATTEMPT_NOTARIZATION=false\nfi\nif [ -z \"$API_KEY_ISSUER_ID\" ]; then\n  echo \"WARNING: Environment variable API_KEY_ISSUER_ID is not set. Notarization will be skipped.\" >&2\n  CAN_ATTEMPT_NOTARIZATION=false\nfi\nif [ -z \"$API_KEY_P8_PATH\" ]; then\n  echo \"WARNING: Environment variable API_KEY_P8_PATH is not set. Notarization will be skipped.\" >&2\n  CAN_ATTEMPT_NOTARIZATION=false\nelif [ ! -f \"$API_KEY_P8_PATH\" ]; then # Also check if the path points to an actual file\n  echo \"WARNING: API Key .p8 file not found at path specified by API_KEY_P8_PATH: '$API_KEY_P8_PATH'. Notarization will be skipped.\" >&2\n  CAN_ATTEMPT_NOTARIZATION=false\nfi\n\nif [ \"$CAN_ATTEMPT_NOTARIZATION\" = true ]; then\n    echo \"INFO: Notarization environment variables appear to be set.\" >&2\nelse\n    echo \"INFO: One or more required environment variables for notarization are missing or invalid.\" >&2\n    echo \"      To enable notarization, please set: API_KEY_ID, API_KEY_ISSUER_ID, API_KEY_P8_PATH.\" >&2\nfi\necho \"-----------------------------------------------------\" >&2\n\n\n# --- Helper Functions (source_nvm, find_developer_id_identities - keep as is) ---\nsource_nvm() {\n  if [ -n \"$NVM_DIR\" ] && [ -s \"$NVM_DIR/nvm.sh\" ]; then source \"$NVM_DIR/nvm.sh\";\n  elif [ -s \"$HOME/.nvm/nvm.sh\" ]; then source \"$HOME/.nvm/nvm.sh\"; fi\n  if ! command -v nvm &> /dev/null; then echo \"ERROR: NVM command not found.\" >&2; return 1; fi\n  return 0\n}\n\nfind_developer_id_identities() {\n  local identities_output developer_id_identities=() identity_line\n  echo \"INFO: Searching for valid 'Developer ID Application' signing identities in keychain...\" >&2\n  identities_output=$(security find-identity -v -p codesigning | awk '{$1=$1;print}')\n  while IFS= read -r identity_line; do\n    if [[ \"$identity_line\" == *\"Developer ID Application:\"* ]]; then\n      local name; name=$(echo \"$identity_line\" | awk -F '\"' '{print $2}')\n      if [ -n \"$name\" ]; then developer_id_identities+=(\"$name\"); fi\n    fi\n  done <<< \"$identities_output\"; for id_name in \"${developer_id_identities[@]}\"; do echo \"$id_name\"; done\n}\n# --- End Helper Functions ---\n\nif [ \"$#\" -ne 3 ]; then\n  echo \"Usage: $0 <output-executable-name> <path-to-js-source-file> <output-folder-path>\" >&2\n  exit 1\nfi\n\nEXE_NAME_ARG=\"$1\"\nJS_SOURCE_FILE_ARG=\"$2\"\nOUTPUT_FOLDER_ARG=\"$3\"\n\necho \"--- DownloadNet macOS SEA Stamper, Signer & Conditional Notarizer ---\"\n# Steps 1-5: Setup, SEA generation, Node binary prep, Injection (keep as is)\necho \"[Step 1/8] Setting up Node.js environment...\" >&2\nif ! source_nvm; then exit 1; fi\nnvm install \"$DEFAULT_NODE_VERSION\" > /dev/null || { echo \"ERROR: Failed to install Node $DEFAULT_NODE_VERSION\" >&2; exit 1; }\nnvm use \"$DEFAULT_NODE_VERSION\" > /dev/null || { echo \"ERROR: Failed to use Node $DEFAULT_NODE_VERSION\" >&2; exit 1; }\necho \"INFO: Using Node version: $(node -v)\" >&2\nif [ ! -f \"$ENTITLEMENTS_FILE_PATH\" ]; then echo \"ERROR: Entitlements file not found at $ENTITLEMENTS_FILE_PATH\" >&2; exit 1; fi\necho \"INFO: Using entitlements file: $ENTITLEMENTS_FILE_PATH\" >&2\nmkdir -p \"$OUTPUT_FOLDER_ARG\"\nTEMP_EXE_PATH=\"./${EXE_NAME_ARG}_sea_final_build\"\necho \"[Step 2/8] Creating sea-config.json...\" >&2\ncat <<EOF > sea-config.json\n{\n  \"main\": \"${JS_SOURCE_FILE_ARG}\",\n  \"output\": \"sea-prep.blob\",\n  \"disableExperimentalSEAWarning\": true,\n  \"useCodeCache\": true,\n  \"assets\": {\n    \"favicon.ico\": \"public/favicon.ico\",\n    \"top.html\": \"public/top.html\",\n    \"style.css\": \"public/style.css\",\n    \"injection.js\": \"public/injection.js\",\n    \"redirector.html\": \"public/redirector.html\"\n  }\n}\nEOF\necho \"[Step 3/8] Generating SEA blob...\" >&2\nnode --experimental-sea-config sea-config.json || { echo \"ERROR: Failed to generate SEA blob.\" >&2; rm -f sea-config.json; exit 1; }\necho \"[Step 4/8] Preparing Node binary...\" >&2\nNODE_EXECUTABLE_PATH=\"$(command -v node)\"\ncp \"$NODE_EXECUTABLE_PATH\" \"$TEMP_EXE_PATH\" || { echo \"ERROR: Failed to copy node binary.\" >&2; rm -f sea-config.json sea-prep.blob; exit 1; }\necho \"INFO: Removing existing signature from copied Node binary $TEMP_EXE_PATH...\" >&2\ncodesign --remove-signature \"$TEMP_EXE_PATH\" 2>/dev/null || echo \"INFO: No existing signature or removal failed (okay).\" >&2\necho \"[Step 5/8] Injecting SEA blob into $TEMP_EXE_PATH...\" >&2\nNPX_CMD=\"npx\"; if ! command -v npx &> /dev/null; then NODE_BIN_PATH=$(dirname \"$(command -v node)\"); if [ -x \"$NODE_BIN_PATH/npx\" ]; then NPX_CMD=\"$NODE_BIN_PATH/npx\"; else echo \"ERROR: npx not found.\" >&2; exit 1; fi; fi\n\"$NPX_CMD\" postject \"$TEMP_EXE_PATH\" NODE_SEA_BLOB sea-prep.blob \\\n  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \\\n  --macho-segment-name NODE_SEA || { echo \"ERROR: postject failed.\"; rm -f sea-config.json sea-prep.blob \"$TEMP_EXE_PATH\"; exit 1; }\necho \"INFO: SEA blob injected.\" >&2\n\n# Step 6: Code Signing (keep as is)\necho \"[Step 6/8] Code Signing Process...\" >&2\nSELECTED_SIGNING_IDENTITY=\"\"\nif [ -n \"${MACOS_CODESIGN_IDENTITY_DOWNLOADNET}\" ]; then SELECTED_SIGNING_IDENTITY=\"${MACOS_CODESIGN_IDENTITY_DOWNLOADNET}\"; echo \"INFO: Using pre-set signing identity: ${SELECTED_SIGNING_IDENTITY}\" >&2\nelse\n    DEVELOPER_ID_CANDIDATES=(); while IFS= read -r line; do DEVELOPER_ID_CANDIDATES+=(\"$line\"); done < <(find_developer_id_identities)\n    NUM_CANDIDATES=${#DEVELOPER_ID_CANDIDATES[@]}\n    if [ \"$NUM_CANDIDATES\" -eq 0 ]; then SELECTED_SIGNING_IDENTITY=\"-\"; echo \"WARNING: No Developer ID certs found. Ad-hoc signing.\" >&2\n    elif [ \"$NUM_CANDIDATES\" -eq 1 ]; then SELECTED_SIGNING_IDENTITY=\"${DEVELOPER_ID_CANDIDATES[0]}\"; echo \"INFO: Auto-selected unique Developer ID cert: $SELECTED_SIGNING_IDENTITY\" >&2\n    else \n        if [ -t 0 ]; then PS3=\"Select certificate by number (or 'a' for ad-hoc, 'q' to quit): \"; select opt in \"${DEVELOPER_ID_CANDIDATES[@]}\" \"Ad-hoc Sign (not for distribution)\" \"Quit\"; do case $REPLY in q|$(($NUM_CANDIDATES+2))) exit 1;; $(($NUM_CANDIDATES+1))) SELECTED_SIGNING_IDENTITY=\"-\"; break;; *) if [[ \"$REPLY\" -ge 1 && \"$REPLY\" -le \"$NUM_CANDIDATES\" ]]; then SELECTED_SIGNING_IDENTITY=\"${DEVELOPER_ID_CANDIDATES[$((REPLY-1))]}\"; break; else echo \"Invalid.\"; fi;; esac; done;\n        else SELECTED_SIGNING_IDENTITY=\"${DEVELOPER_ID_CANDIDATES[0]}\"; echo \"WARNING: Non-interactive, multiple certs, using first: $SELECTED_SIGNING_IDENTITY\" >&2; fi\n        echo \"INFO: You selected: $SELECTED_SIGNING_IDENTITY\" >&2\n    fi\nfi\nif [ -z \"$SELECTED_SIGNING_IDENTITY\" ]; then echo \"ERROR: No signing identity selected.\" >&2; exit 1; fi\necho \"INFO: Signing $TEMP_EXE_PATH with identity: '$SELECTED_SIGNING_IDENTITY', bundle ID: '$MACOS_APP_BUNDLE_ID', entitlements: '$ENTITLEMENTS_FILE_PATH'\" >&2\nSIGN_OPTIONS=\"--force --deep --timestamp --identifier \\\"$MACOS_APP_BUNDLE_ID\\\" --entitlements \\\"$ENTITLEMENTS_FILE_PATH\\\"\"\nif [ \"$SELECTED_SIGNING_IDENTITY\" != \"-\" ]; then SIGN_OPTIONS=\"$SIGN_OPTIONS --options runtime\"; fi\neval \"codesign $SIGN_OPTIONS --sign \\\"$SELECTED_SIGNING_IDENTITY\\\" \\\"$TEMP_EXE_PATH\\\"\"\nif [ $? -ne 0 ]; then echo \"ERROR: codesign failed.\" >&2; exit 1; fi\necho \"INFO: Code signing successful.\" >&2\n\n# Step 7: Verifying Signature and Testing Execution\necho \"[Step 7/8] Verifying Signature and Testing Execution...\" >&2\necho \"INFO: Verifying signature for $TEMP_EXE_PATH...\" >&2\ncodesign --verify --strict --verbose=4 \"$TEMP_EXE_PATH\" || { echo \"ERROR: codesign --verify failed.\" >&2; exit 1; }\necho \"INFO: Signature verified.\" >&2\necho \"INFO: Displaying signature details (check entitlements)...\" >&2\ncodesign --display --entitlements - --verbose=2 \"$TEMP_EXE_PATH\"\necho \"INFO: Assessing with spctl for $TEMP_EXE_PATH...\" >&2\nspctl_output=$(spctl --assess --type execute --verbose \"$TEMP_EXE_PATH\" 2>&1) || true\necho \"$spctl_output\"\n\nAPP_SIGNED_WITH_DEV_ID=false\nif [ \"$SELECTED_SIGNING_IDENTITY\" != \"-\" ]; then\n    APP_SIGNED_WITH_DEV_ID=true\nfi\n\nELIGIBLE_FOR_NOTARIZATION=false\nif [ \"$APP_SIGNED_WITH_DEV_ID\" = true ] && [[ \"$spctl_output\" == *\"source=Unnotarized Developer ID\"* || \"$spctl_output\" == *\"rejected\"* ]]; then\n    echo \"INFO: App signed with Developer ID and appears unnotarized. Eligible for notarization attempt.\" >&2\n    ELIGIBLE_FOR_NOTARIZATION=true\nelif [ \"$APP_SIGNED_WITH_DEV_ID\" = true ] && [[ \"$spctl_output\" == *\": accepted\"* && (\"$spctl_output\" == *\"source=Notarized Developer ID\"* || \"$spctl_output\" == *\"source=Apple notarization\"*) ]]; then\n    echo \"INFO: App appears to be already signed with Developer ID and notarized.\" >&2\nelif [ \"$SELECTED_SIGNING_IDENTITY\" == \"-\" ]; then\n    echo \"INFO: App is ad-hoc signed. Notarization is not applicable.\" >&2\nelse\n    echo \"WARNING: App status is unclear or not suitable for notarization based on spctl assessment.\" >&2\nfi\n\nPROCEED_WITH_NOTARIZATION_USER_CONFIRMED=\"no\"\nif [ \"$ELIGIBLE_FOR_NOTARIZATION\" = true ]; then\n    echo \"---------------------------------------------------------------------\"\n    echo \"TESTING EXECUTABLE: The application '$TEMP_EXE_PATH' will now run in the foreground.\"\n    echo \"Please interact with it to verify its basic functionality.\"\n    echo \"Once you are done testing and have exited the application (or used Ctrl+C), \"\n    echo \"this script will ask for your confirmation to notarize.\"\n    echo \"---------------------------------------------------------------------\"\n    chmod +x \"$TEMP_EXE_PATH\"\n    if ! \"$TEMP_EXE_PATH\"; then\n        echo \"WARNING: Application exited with a non-zero status during test run.\" >&2\n    fi\n    echo \"---------------------------------------------------------------------\"\n    if [ -t 0 ]; then \n        read -r -p \"Do you want to proceed with notarization for '$EXE_NAME_ARG' ? (y/N): \" USER_CONFIRM_SUCCESS\n        if [[ \"$USER_CONFIRM_SUCCESS\" =~ ^[Yy]$ ]]; then\n            echo \"INFO: User confirmed successful execution.\"\n            PROCEED_WITH_NOTARIZATION_USER_CONFIRMED=\"yes\"\n        else\n            echo \"INFO: Person indicated a preference to skip notarization.\"\n        fi\n    else \n        echo \"WARNING: Non-interactive environment. Cannot get user confirmation for test run.\" >&2\n        echo \"         To notarize in CI, ensure MACOS_CODESIGN_IDENTITY_DOWNLOADNET is set and notarization env vars are present.\" >&2\n        echo \"         And consider adding an automated test or always notarizing if Dev ID signed.\" >&2\n    fi\nfi\n\n\n# Step 8: Conditional Notarization and Finalization\necho \"[Step 8/8] Conditional Notarization and Finalization...\" >&2\nFINAL_NOTARIZATION_DECISION=\"no\"\n\nif [ \"$ELIGIBLE_FOR_NOTARIZATION\" = true ] && [ \"$PROCEED_WITH_NOTARIZATION_USER_CONFIRMED\" = \"yes\" ] && [ \"$CAN_ATTEMPT_NOTARIZATION\" = true ]; then\n    if [ -x \"$NOTARIZE_SCRIPT_PATH\" ]; then\n        echo \"INFO: Proceeding to notarization for $TEMP_EXE_PATH...\" >&2\n        # Pass the temporary executable path and bundle ID to the notarization script\n        if \"$NOTARIZE_SCRIPT_PATH\" \"$TEMP_EXE_PATH\" \"$MACOS_APP_BUNDLE_ID\"; then\n            echo \"INFO: Notarization process reported success for $TEMP_EXE_PATH.\" >&2\n            FINAL_NOTARIZATION_DECISION=\"yes\" # Assume success from script\n        else\n            echo \"ERROR: Notarization process reported failure for $TEMP_EXE_PATH.\" >&2\n            # Notarization script should output details. The main build might still succeed but app won't be notarized.\n        fi\n    else\n        echo \"WARNING: Notarization script $NOTARIZE_SCRIPT_PATH not found or not executable. Skipping actual notarization.\" >&2\n        echo \"         (CAN_ATTEMPT_NOTARIZATION was true, but script is missing)\" >&2\n    fi\nelif [ \"$ELIGIBLE_FOR_NOTARIZATION\" = true ]; then # Eligible, but user said no or env vars missing\n    if [ \"$CAN_ATTEMPT_NOTARIZATION\" = false ]; then\n        echo \"INFO: Notarization skipped because required environment variables (API_KEY_ID, etc.) are not set.\" >&2\n    elif [ \"$PROCEED_WITH_NOTARIZATION_USER_CONFIRMED\" = \"no\" ]; then\n        echo \"INFO: Notarization skipped based on test run outcome or user choice.\" >&2\n    fi\nfi\n\n\nFINAL_EXE_PATH=\"$OUTPUT_FOLDER_ARG/$EXE_NAME_ARG\"\necho \"INFO: Moving $TEMP_EXE_PATH to $FINAL_EXE_PATH...\" >&2\nmv \"$TEMP_EXE_PATH\" \"$FINAL_EXE_PATH\" || { echo \"ERROR: Failed to move executable.\"; exit 1; }\n\necho \"INFO: Cleaning up temporary files...\" >&2\nrm -f sea-config.json sea-prep.blob\n\necho \"--- DownloadNet macOS SEA Stamping & Signing Complete ---\" >&2\necho \"SUCCESS: Executable created at: $FINAL_EXE_PATH\" >&2\nif [ \"$FINAL_NOTARIZATION_DECISION\" = \"yes\" ]; then\n    echo \"INFO: The executable should be notarized.\"\nelif [ \"$ELIGIBLE_FOR_NOTARIZATION\" = true ]; then # Was eligible but didn't get notarized for some reason\n    echo \"WARNING: The executable is signed with Developer ID but was NOT notarized.\"\nelif [ \"$SELECTED_SIGNING_IDENTITY\" == \"-\" ]; then\n    echo \"INFO: The executable is ad-hoc signed (not for distribution, notarization not applicable).\"\nelse\n    echo \"INFO: Notarization was not attempted or was not applicable for other reasons.\"\nfi\n"
  },
  {
    "path": "stampers/macos.sh",
    "content": "#!/bin/bash\n\nsource $HOME/.nvm/nvm.sh\n\n# Variables\nEXE_NAME=\"$1\"\nJS_SOURCE_FILE=\"$2\"\nOUTPUT_FOLDER=\"$3\"\n\n# Ensure nvm is installed\nif ! command -v nvm &> /dev/null\nthen\n  echo \"nvm not found. Installing...\"\n  curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash\n  # shellcheck source=/dev/null\n  source ~/.nvm/nvm.sh\nfi\n\n# Use Node 22\nnvm install 22\nnvm use 22\n\n# Create sea-config.json\ncat <<EOF > sea-config.json\n{\n  \"main\": \"${JS_SOURCE_FILE}\",\n  \"output\": \"sea-prep.blob\",\n  \"disableExperimentalSEAWarning\": true,\n  \"useCodeCache\": true,\n  \"assets\": {\n    \"favicon.ico\": \"public/favicon.ico\",\n    \"top.html\": \"public/top.html\",\n    \"style.css\": \"public/style.css\",\n    \"injection.js\": \"public/injection.js\",\n    \"redirector.html\": \"public/redirector.html\"\n  }\n}\nEOF\n\n# Generate the blob\nnode --experimental-sea-config sea-config.json\n\n# Copy node binary\ncp \"$(command -v node)\" \"$EXE_NAME\"\n\n# Remove the signature of the binary\ncodesign --remove-signature \"$EXE_NAME\"\n\n# Inject the blob\nnpx postject \"$EXE_NAME\" NODE_SEA_BLOB sea-prep.blob \\\n  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \\\n  --macho-segment-name NODE_SEA\n\n# Sign the binary\ncodesign --sign - \"$EXE_NAME\"\n\n# Move the executable to the output folder\nmv \"$EXE_NAME\" \"$OUTPUT_FOLDER\"\n\n# Clean up\nrm sea-config.json sea-prep.blob\n\n"
  },
  {
    "path": "stampers/nix.sh",
    "content": "#!/bin/bash\n\nsource $HOME/.nvm/nvm.sh\n\n# Variables\nEXE_NAME=\"$1\"\nJS_SOURCE_FILE=\"$2\"\nOUTPUT_FOLDER=\"$3\"\n\n# Ensure nvm is installed\nif ! command -v nvm &> /dev/null\nthen\n  echo \"nvm not found. Installing...\"\n  curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash\n  # shellcheck source=/dev/null\n  source ~/.nvm/nvm.sh\nfi\n\n# Use Node 22\nnvm install 22\nnvm use 22\n\n# Create sea-config.json\ncat <<EOF > sea-config.json\n{\n  \"main\": \"${JS_SOURCE_FILE}\",\n  \"output\": \"sea-prep.blob\",\n  \"disableExperimentalSEAWarning\": true,\n  \"useCodeCache\": true,\n  \"assets\": {\n    \"favicon.ico\": \"public/favicon.ico\",\n    \"top.html\": \"public/top.html\",\n    \"style.css\": \"public/style.css\",\n    \"injection.js\": \"public/injection.js\",\n    \"redirector.html\": \"public/redirector.html\"\n  }\n}\nEOF\n\n# Generate the blob\nnode --experimental-sea-config sea-config.json\n\n# Copy node binary\ncp \"$(command -v node)\" \"$EXE_NAME\"\n\n# Inject the blob\nnpx postject \"$EXE_NAME\" NODE_SEA_BLOB sea-prep.blob \\\n  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2\n\n# Move the executable to the output folder\nmv \"$EXE_NAME\" \"$OUTPUT_FOLDER\"\n\n# Clean up\nrm sea-config.json sea-prep.blob\n\n"
  },
  {
    "path": "stampers/notarize_macos.sh",
    "content": "#!/bin/bash\n\n# create-notarized-pkg.sh\n# Creates a notarized and stapled .pkg installer from a code-signed binary, signing the entire package.\n\n# Usage\nusage() {\n    echo \"Usage: $0 --binary <path> --keychain-profile <profile> --bundle-id <id> --version <version> --installer-cert <installer-cert-name>\"\n    echo \"Example: $0 --binary ./bin/dn-macos --keychain-profile notarization-profile --bundle-id com.DOSAYGO.DownloadNet --version 4.5.1 --installer-cert 'Developer ID Installer: DOSAYGO\"\n    exit 1\n}\n\n# Parse command-line arguments\nwhile [ \"$#\" -gt 0 ]; do\n    case \"$1\" in\n        --binary) BINARY_PATH=\"$2\"; shift 2 ;;\n        --keychain-profile) KEYCHAIN_PROFILE=\"$2\"; shift 2 ;;\n        --bundle-id) BUNDLE_ID=\"$2\"; shift 2 ;;\n        --version) VERSION=\"$2\"; shift 2 ;;\n        --installer-cert) INSTALLER_CERT=\"$2\"; shift 2 ;;\n        *) echo \"Unknown option: $1\"; usage ;;\n    esac\ndone\n\n# Validate inputs\nif [ -z \"$BINARY_PATH\" ] || [ -z \"$KEYCHAIN_PROFILE\" ] || [ -z \"$BUNDLE_ID\" ] || [ -z \"$VERSION\" ] || [ -z \"$INSTALLER_CERT\" ]; then\n    echo \"Error: All arguments are required.\"\n    usage\nfi\n\nif [ ! -f \"$BINARY_PATH\" ]; then\n    echo \"Error: Binary not found at $BINARY_PATH\"\n    exit 1\nfi\n\n# Verify binary is code-signed\necho \"Verifying signature of input binary: $BINARY_PATH\"\nif ! codesign --verify --verbose \"$BINARY_PATH\"; then\n    echo \"Error: Input binary is not code-signed or signature is invalid.\"\n    exit 1\nfi\n\n# Set up working directory\nBUILD_DIR=\"$HOME/build\"\necho \"Cleaning and setting up working directory: $BUILD_DIR\"\nrm -rf \"$BUILD_DIR\"\nmkdir -p \"$BUILD_DIR/pkg_root/usr/local/bin\"\n\n# Copy binary to package root\nBINARY_NAME=$(basename \"$BINARY_PATH\")\ncp \"$BINARY_PATH\" \"$BUILD_DIR/pkg_root/usr/local/bin/$BINARY_NAME\"\nchmod +x \"$BUILD_DIR/pkg_root/usr/local/bin/$BINARY_NAME\"\n\n# Verify signature after copying\necho \"Verifying signature of copied binary: $BUILD_DIR/pkg_root/usr/local/bin/$BINARY_NAME\"\nif ! codesign --verify --verbose \"$BUILD_DIR/pkg_root/usr/local/bin/$BINARY_NAME\"; then\n    echo \"Error: Copied binary lost its signature or is invalid.\"\n    exit 1\nfi\n\n# Create component package\nCOMPONENT_PKG=\"$BUILD_DIR/component.pkg\"\npkgbuild --root \"$BUILD_DIR/pkg_root\" \\\n         --identifier \"$BUNDLE_ID\" \\\n         --version \"$VERSION\" \\\n         --install-location \"/\" \\\n         \"$COMPONENT_PKG\"\n\nif [ $? -ne 0 ]; then\n    echo \"Error: Failed to create component package.\"\n    exit 1\nfi\n\n# Create distribution package\nUNSIGNED_DISTRIBUTION_PKG=\"$BUILD_DIR/unsigned-notarized-$BINARY_NAME-$VERSION.pkg\"\nproductbuild --package \"$COMPONENT_PKG\" \\\n             --identifier \"$BUNDLE_ID\" \\\n             --version \"$VERSION\" \\\n             \"$UNSIGNED_DISTRIBUTION_PKG\"\n\nif [ $? -ne 0 ]; then\n    echo \"Error: Failed to create distribution package.\"\n    exit 1\nfi\n\n# Sign the distribution package\nDISTRIBUTION_PKG=\"notarized-$BINARY_NAME-$VERSION.pkg\"\necho \"Signing distribution package with Installer certificate: $INSTALLER_CERT\"\nproductsign --sign \"$INSTALLER_CERT\" \"$UNSIGNED_DISTRIBUTION_PKG\" \"$DISTRIBUTION_PKG\"\n\nif [ $? -ne 0 ]; then\n    echo \"Error: Failed to sign distribution package.\"\n    exit 1\nfi\n\n# Notarize the package\necho \"Submitting $DISTRIBUTION_PKG for notarization...\"\nSUBMISSION_OUTPUT=$(xcrun notarytool submit \"$DISTRIBUTION_PKG\" --keychain-profile \"$KEYCHAIN_PROFILE\" --wait 2>&1)\n\nif [ $? -ne 0 ]; then\n    echo \"Error: Notarization submission failed.\"\n    echo \"$SUBMISSION_OUTPUT\"\n    exit 1\nfi\n\n# Extract submission ID\nSUBMISSION_ID=$(echo \"$SUBMISSION_OUTPUT\" | grep \"id:\" | head -1 | awk '{print $2}')\n\nif [ -z \"$SUBMISSION_ID\" ]; then\n    echo \"Error: Could not retrieve submission ID.\"\n    exit 1\nfi\n\necho \"Notarization submission ID: $SUBMISSION_ID\"\n\n# Check notarization status\nLOG_OUTPUT=$(xcrun notarytool log \"$SUBMISSION_ID\" --keychain-profile \"$KEYCHAIN_PROFILE\")\nSTATUS=$(echo \"$LOG_OUTPUT\" | grep '\"status\":' | awk -F'\"' '{print $4}')\n\nif [ \"$STATUS\" != \"Accepted\" ]; then\n    echo \"Error: Notarization failed. Status: $STATUS\"\n    echo \"Notarization log:\"\n    echo \"$LOG_OUTPUT\"\n    exit 1\nfi\n\necho \"Notarization successful. Status: $STATUS\"\n\n# Staple the notarization ticket\nxcrun stapler staple \"$DISTRIBUTION_PKG\"\n\nif [ $? -ne 0 ]; then\n    echo \"Error: Failed to staple notarization ticket.\"\n    exit 1\nfi\n\necho \"Successfully created notarized and stapled package: $DISTRIBUTION_PKG\"\n\n# Clean up\nrm -rf \"$BUILD_DIR\"\n\necho \"Package is ready for distribution. Upload $DISTRIBUTION_PKG to your GitHub release.\"\n"
  },
  {
    "path": "stampers/win.bat",
    "content": "@echo off\nsetlocal\n\n:: Check for required arguments\nif \"%~3\"==\"\" (\n  echo Usage: %0 executable_name js_source_file output_folder\n  exit /b 1\n)\n\n:: Define variables from command line arguments\nset \"EXE_NAME=%~1\"\nset \"JS_SOURCE_FILE=%~2\"\nset \"OUTPUT_FOLDER=%~3\"\nset \"SEA_CONFIG=sea-config.json\"\n\necho \"Exe name: %EXE_NAME%\"\necho \"JS source: %JS_SOURCE_FILE%\"\necho \"Output folder: %OUTPUT_FOLDER%\"\necho \"SEA Config file: %SEA_CONFIG%\"\n\nset /p \"user_input=Press enter to continue\"\n\n:: Ensure output folder exists\nif not exist \"%OUTPUT_FOLDER%\" mkdir \"%OUTPUT_FOLDER%\"\n\n:: Create configuration file for SEA\n(\necho { \necho   \"main\": \"%JS_SOURCE_FILE%\", \necho   \"output\": \"sea-prep.blob\", \necho   \"disableExperimentalSEAWarning\": true, \necho   \"useCodeCache\": true, \necho   \"assets\": { \necho     \"favicon.ico\": \"public/favicon.ico\", \necho     \"top.html\": \"public/top.html\", \necho     \"style.css\": \"public/style.css\", \necho     \"injection.js\": \"public/injection.js\", \necho     \"redirector.html\": \"public/redirector.html\" \necho   } \necho }\n) > \"%OUTPUT_FOLDER%\\%SEA_CONFIG%\"\n\n:: Generate the blob to be injected\nnode --experimental-sea-config \"%OUTPUT_FOLDER%\\%SEA_CONFIG%\"\n\n:: Copy the node executable and rename\nnode -e \"require('fs').copyFileSync(process.execPath, '%OUTPUT_FOLDER%\\%EXE_NAME%')\"\n\n:: Optionally, remove signature from the binary (use signtool if necessary, or skip this step)\nsigntool.exe remove /s \"%OUTPUT_FOLDER%\\%EXE_NAME%\"\n\n:: Inject the blob into the copied binary\nnpx postject \"%OUTPUT_FOLDER%\\%EXE_NAME%\" NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2\n\n:: Clean up\necho Application built successfully.\n\n:end\n\n"
  },
  {
    "path": "test.sh",
    "content": "#!/bin/bash\n\n# Variables\nEXE_NAME=\"$1\"\nJS_SOURCE_FILE=\"$2\"\nOUTPUT_FOLDER=\"$3\"\n\n# Ensure nvm is installed\nif ! command -v nvm &> /dev/null\nthen\n  echo \"nvm not found. Installing...\"\n  curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash\n  # shellcheck source=/dev/null\n  source ~/.nvm/nvm.sh\nfi\n\n# Use Node 22\nnvm install 22\nnvm use 22\n\n# Create sea-config.json\ncat <<EOF > sea-config.json\n{\n  \"main\": \"${JS_SOURCE_FILE}\",\n  \"output\": \"sea-prep.blob\",\n  \"assets\": {\n    \"index.html\": \"public/index.html\",\n    \"top.html\": \"public/top.html\",\n    \"style.css\": \"public/style.css\",\n    \"injection.js\": \"public/injection.js\",\n    \"redirector.html\": \"public/redirector.html\"\n  }\n}\nEOF\n\n# Generate the blob\nnode --experimental-sea-config sea-config.json\n\n# Copy node binary\ncp \"$(command -v node)\" \"$EXE_NAME\"\n\n# Remove the signature of the binary\ncodesign --remove-signature \"$EXE_NAME\"\n\n# Inject the blob\nnpx postject \"$EXE_NAME\" NODE_SEA_BLOB sea-prep.blob \\\n  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \\\n  --macho-segment-name NODE_SEA\n\n# Sign the binary\ncodesign --sign - \"$EXE_NAME\"\n\n# Move the executable to the output folder\nmv \"$EXE_NAME\" \"$OUTPUT_FOLDER\"\n\n# Clean up\nrm sea-config.json sea-prep.blob\n\n"
  }
]