[
  {
    "path": ".gitignore",
    "content": "node_modules\ndist\nprivate\ntsconfig.tsbuildinfo\n.env\ndeploy\n*#\n/examples/credentials.json\n"
  },
  {
    "path": ".ignore",
    "content": "package-lock.json"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"trailingComma\": \"all\",\n  \"bracketSpacing\": true,\n  \"printWidth\": 120,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"jsxBracketSameLine\": false,\n  \"arrowParens\": \"avoid\"\n}\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "Copyright (c) 2024 Computing Den, https://computing-den.com\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Unforget\n\n![screenshot](doc/screenshots.png)\n\n*Start now without registering at [unforget.computing-den.com](https://unforget.computing-den.com/demo).*\n\nUnforget is a minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js) featuring:\n\n- [x] Offline first\n- [x] Privacy first\n- [x] Progressive web app\n- [x] Open source MIT License\n- [x] End-to-end encrypted sync\n- [x] Desktop, Mobile, Web\n- [x] Markdown support\n- [x] Self hosted and cloud options\n- [x] One-click data export as JSON\n- [x] Optional one-click installation\n- [x] Public APIs, create your own client\n- [x] Import Google Keep\n- [x] Import Apple Notes\n- [x] Import Standard Notes\n\n\n*Unforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.*\n\n*Contact us at sean@computing-den.com*\n\n\n# Easy Signup\n\n[Sign up](https://unforget.computing-den.com/login) for free to back up your notes safely to the cloud fully encrypted and sync across devices.\n\n*No email or phone required.*\n\n# Optional installation\n\nUse it directly in your browser or install:\n\n| Browser         | Installation                |\n|-----------------|-----------------------------|\n| Chrome          | Install icon in the URL bar |\n| Edge            | Install icon in the URL bar |\n| Android Browser | Menu → Add to Home Screen   |\n| Safari Desktop  | Share → Add to Dock         |\n| Safari iOS      | Share → Add to Home Screen  |\n| Firefox Desktop | *cannot install*            |\n| Firefox Android | Install icon in the URL bar |\n\n# Organization and Workflow\n\nNotes are organized **chronologically**, with pinned notes displayed at the top.\n\nThis organization has proven very effective despite its simplicity. The **search is very fast** (and done offline), allowing you to quickly narrow down notes by entering a few phrases. Additionally, you can search for non-alphabetic characters, enabling the use of **tags** such as #idea, #project, #work, #book, etc.\n\nThere is **no limit** to the size of a note. For larger notes, you can insert a `---` on a line by itself to **collapse** the rest of the note.\n\nNotes are **immediately saved** as you type and synced every few seconds.\n\nIf you edit a note from two devices and a **conflict** occurs during sync, the most recent edit will take precedence.\n\n# Security and Privacy\n\nUnforget does not receive or store any personal data. No email or phone is required to sign up. As long as you pick a strong password, your notes will be stored in the cloud fully encrypted and safe.\n\nOnly your username and note modification dates are visible to Unforget servers.\n\n# Text Formatting\n\nThe main differences with the [Github flavored markdown](https://github.github.com/gfm/) are:\n- If the first line of a note is followed by a blank line, it is a H1 header.\n- Anything after the first horizontal rule `---` in a note will be hidden and replaced with a \"show more\" button that will expand the note.\n\n~~~\n# H1 header\n## H2 header\n### H3 header\n#### H4 header\n##### H5 header\n###### H6 header\n\n*This is italic.*.\n\n**This is bold.**.\n\n***This is bold and italic.***\n\n~~This is strikethrough~~\n\n\n- This is a bullet point\n- Another bullet point\n  - Inner bullet point\n- [ ] This is a checkbox\n  And more text related to the checkbox.\n\n1. This is an ordered list item\n2. And another one\n\n[this is a link](https://unforget.computing-den.com)\n\nInline `code` using back-ticks.\n\nBlock of code:\n\n```javascript\nfunction plusOne(a) {\n  return a + 1;\n}\n```\n\n\n| Tables        | Are           | Cool  |\n| ------------- |:-------------:| -----:|\n| col 3 is      | right-aligned | $1600 |\n| col 2 is      | centered      |   $12 |\n\n\nHorizontal rule:\n\n---\n\n\n~~~\n\n# Build and Self Host\n\nTo build Unforget for production, put a `.env` file in the project's root directory:\n\n```\nPORT=3000\nNODE_ENV=production\nDISABLE_CACHE=0\nLOG_TO_CONSOLE=0\nFORWARD_LOGS_TO_SERVER=0\nFORWARD_ERRORS_TO_SERVER=0\n```\n\nand then run\n\n```\ncd unforget/\nnpm run build\nnpm run start\n\n```\n\nIt is recommended to use [Nginx as a reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) and set up SSL certificate using [Let's Encrypt](https://letsencrypt.org/).\n\n# Development\n\nTo build and run Unforget in development mode, put a `.env` file in the project's root directory:\n\n```\nPORT=3000\nNODE_ENV=development\nDISABLE_CACHE=1\nLOG_TO_CONSOLE=1\nFORWARD_LOGS_TO_SERVER=0\nFORWARD_ERRORS_TO_SERVER=0\n```\n\nand then run\n\n```\ncd unforget/\nnpm install\nnpm run dev\n\n```\n\nThis will build the project and watch for changes in the source files.\n\n# Public APIs - write your own client\n\nHere, all paths are relative to either the official server at [https://unforget.computing-den.com](https://unforget.computing-den.com) or your own server if you're self hosting.\n\n## Examples\n\nIn the [examples/](examples/) directory you will find example code for TypeScript and Python.\n\nTo run the **Typescript** example:\n\n``` bash\ncd examples/\n\n# Signup\nnpx tsx example.ts signup USERNAME PASSWORD\n\n# Login\nnpx tsx example.ts login USERNAME PASSWORD\n\n# Create new note\nnpx tsx example.ts create \"Hello world!\"\n\n# Get all notes\nnpx tsx example.ts get\n\n# Get note by ID\nnpx tsx example.ts get ID\n```\n\nTo run the **Python** example:\n\n``` bash\ncd examples/\n\n# Signup\npython3 example.py signup USERNAME PASSWORD\n\n# Login\npython3 example.py login USERNAME PASSWORD\n\n# Create new note\npython3 example.py create \"Hello world!\"\n\n# Get all notes\npython3 example.py get\n\n# Get note by ID\npython3 example.py get ID\n```\n\n## Note Types\n\n```ts\ntype Note = {\n\n  // UUID version 4\n  id: string;\n\n  // Deleted notes have text: null\n  text: string | null;\n\n  // ISO 8601 format\n  creation_date: string;\n  \n  // ISO 8601 format\n  modification_date: string;\n  \n  // 0 means deleted, 1 means not deleted\n  not_deleted: number;\n  \n  // 0 means archived, 1 means not archived\n  not_archived: number;\n  \n  // 0 means not pinned, 1 means pinned\n  pinned: number;\n\n  // A higher number means higher on the list\n  // Usually, by default it's milliseconds since the epoch\n  order: number;\n\n}\n\ntype EncryptedNote = {\n\n  // UUID version 4\n  id: string;\n\n  // ISO 8601 format\n  modification_date: string;\n  \n  // The encrypted Note in base64 format\n  encrypted_base64: string;\n  \n  // Initial vector, a random number, that was used for encrypting this specific note\n  iv: string;\n\n}\n\n```\n\nThe server only knows about ```EncryptedNote``` and never sees the actual ```Note```. So, the client must encrypt before sending to and decrypt after receiving notes from the server.\n\nSide note: the reason for using number (0 and 1) instead of boolean is to make it easier to store notes in SQLite which doesn't support boolean. And the reason why some of these fields are flipped (```not_deleted``` instead of ```deleted```) is to facilitate the use of IndexedDB which doesn't support indexing by multiple keys in arbitrary order.\n\n\n## Signup, Login, Logout\n\nTo sign up, send a POST request to ```/api/signup``` with a JSON payload of type ```SignupData```:\n\n```ts\ntype SignupData = {\n  username: string;\n  password_client_hash: string;\n  encryption_salt: string;\n}\n```\n\nTo log in, send a POST request to ```/api/login``` with a JSON payload of type ```LoginData```:\n\n```ts\ntype LoginData = {\n  username: string;\n  password_client_hash: string;\n}\n```\n\nIn both cases, if the credentials are wrong you will receive a 401 error. Otherwise, the server will respond with ```LoginResponse``` and code 200:\n\n```ts\ntype LoginResponse = {\n  username: string;\n  token: string;\n  encryption_salt: string;\n}\n```\n\nTo log out, send a POST request to ```/api/login?token=TOKEN```\n\nIn the following sections, all the requests to the server must include the ```token``` either as a query parameter in the URL (e.g. ```/api/delta-sync?token=XXX```) or as a cookie named ```unforget_token```.\n\nNotice that we never send the raw password to the server. Instead we calculate its hash as ```password_client_hash``` which is derived from the username, password, and a static random number. It is important to use the exact same algorithm for calculating the hash if you want to be able to use the official Unforget client as well as your own. The ```encryption_salt``` is a random number used to derive the key for encryption and decryption of notes. It is stored on the server and provided on login. The [examples](#examples) show how to calculate the hash and generate the salt.\n\n## Get Notes\n\nSend a POST request to ```/api/get-notes?token=TOKEN``` to get all notes. Optionally you can provide a JSON payload of type ```{ids: string[]}``` to get specific notes.\n\nYou will receive ```EncryptedNote[]```.\n\n## Merge Notes\n\nSend a POST request to ```/api/merge-notes?token=TOKEN``` with a JSON payload of type ```{notes: EncryptedNote[]}```.\n\nIf the note doesn't already exist, it will be added.\nIf its ```modification_date``` is larger than the existing note, it will replace the existing note.\nOtherwise, it will be thrown away.\n\n## Delete Notes\n\nTo delete a note set its `text: null` and `not_deleted: 0` and [merge](#merge-notes) it. This way, the stub will stay in the database and the fact that it was deleted will propogate to all the other clients.\n\n## Sync and Merge\n\nFor a long-running client, instead of using [Get Notes](#get-notes) and [Merge Notes](#merge-notes), you can use sync in the following manner.\n\nThe client and the server each maintain a queue of changes to send to each other as well as a sync number. The exchange of these changes is called a **delta sync**.\n\nThe sync number is 0 at login and is incremented by each side only after all the received changes have been merged and stored. At the start of each delta sync, if their sync numbers differ, it indicates that something went wrong in the last delta sync and so they must do a queue sync.\n\nA **queue sync** is when each side sends its sync number along with a list of IDs and modification dates of all the notes that it knows about. After a queue sync, both sides will know which changes the other side lacks and therefore can update their own queue and sync number.\n\nWhen the sync number is 0 (immediately after login), the server will send all notes in the first delta sync.\n\nTo perform a **delta sync**, send a POST request to ```/api/delta-sync?token=TOKEN``` with a JSON payload of type ```SyncData```:\n\n```ts\ntype SyncData = {\n  notes: EncryptedNote[];\n  syncNumber: number;\n}\n```\n\nIf the server agrees with the ```syncNumber```, it will respond with ```DeltaSyncResNormal``` which includes the changes stored on the server for that client since the last sync. Otherwise, the server will respond with ```PartialSyncResRequireQueueSync``` requiring the client to initiate a queue sync.\n\n```ts\ntype DeltaSyncResNormal = {\n  type: 'ok';\n  notes: EncryptedNote[];\n  syncNumber: number;\n}\n\ntype DeltaSyncResRequireQueueSync = {\n  type: 'require_queue_sync';\n}\n```\n\nTo perform a **queue sync**, send a POST request to ```/api/queue-sync?token=TOKEN``` with a JSON payload of type ```SyncHeadsData``` including the heads of all the notes known by the client and its sync number. You will then receive another ```SyncHeadsData``` including the heads of all the notes known by the server for that user along with the server's sync number for that client.\n\n```ts\ntype SyncHeadsData = {\n  noteHeads: NoteHead[];\n  syncNumber: number;\n}\n\ntype NoteHead = {\n  id: string;\n  modification_date: string;\n}\n```\n\nAfter a queue sync, each side updates its queue to include the changes the other side is mising as well as setting the new sync number to be the larger sync number + 1.\n\nIt is important that the client and the server agree on how the **merging** of the notes is done so that they end up with a consistent state. We say that note A must replace note B if ```A.id == B.id``` and ```A.modification_date > B.modification_date```.\n\n## Encryption and Decryption\n\nThe details of encryption and decryption are more easily explained in code. See the [Examples](#examples) section.\n\n## Error handling\n\nAll the API calls will return an object of type ```ServerError``` when encountering an error with a status code >= 400:\n\n```ts\ntype ServerError {\n  message: string;\n  code: number;\n  type: 'app_requires_update' | 'generic';\n}\n```\n\nIf you receive an error with type ```app_requires_update``` that indicates that you are using an older version of the API that is no longer supported.\n"
  },
  {
    "path": "esbuild.config.mjs",
    "content": "import 'dotenv/config';\nimport esbuild from 'esbuild';\n\nconst context = await esbuild.context({\n  entryPoints: ['src/client/index.tsx', 'src/client/style.css', 'src/client/serviceWorker.ts'],\n  outdir: 'dist/public',\n  minify: process.env.NODE_ENV === 'production',\n  bundle: true,\n  sourcemap: true,\n  format: 'esm',\n  treeShaking: true,\n  define: Object.fromEntries(Object.keys(process.env).map(key => [`process.env.${key}`, `\"${process.env[key]}\"`])),\n  plugins: [reporterPlugin()],\n  loader: { '.svg': 'dataurl', '.txt': 'text', '.md': 'text' },\n});\n\nif (process.argv.includes('--watch')) {\n  console.log('Watching ...');\n  await context.watch();\n} else {\n  console.log('Building ...');\n  await context.rebuild();\n  await context.dispose();\n}\n\nfunction reporterPlugin() {\n  return {\n    name: 'reporter',\n    setup(build) {\n      build.onEnd(result => console.log(`Done - ${result.errors.length} errors, ${result.warnings.length} warnings`));\n    },\n  };\n}\n"
  },
  {
    "path": "examples/example.py",
    "content": "# The following was generated by ChatGPT4o from the original example.ts with some trial and error.\n# It was manually inspected and tested.\n\nimport json\nimport sys\nimport uuid\nimport requests\nimport hashlib\nfrom base64 import b64encode, b64decode, urlsafe_b64decode, urlsafe_b64encode\nfrom datetime import datetime\nimport os\nfrom cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC\nfrom cryptography.hazmat.primitives import hashes\nfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes\nfrom cryptography.hazmat.backends import default_backend\n\nBASE_URL = 'https://unforget.computing-den.com'\n\nclass Note:\n    def __init__(self, id, text, creation_date, modification_date, not_deleted, not_archived, pinned, order):\n        self.id = id\n        self.text = text\n        self.creation_date = creation_date\n        self.modification_date = modification_date\n        self.not_deleted = not_deleted\n        self.not_archived = not_archived\n        self.pinned = pinned\n        self.order = order\n\nclass EncryptedNote:\n    def __init__(self, id, modification_date, encrypted_base64, iv):\n        self.id = id\n        self.modification_date = modification_date\n        self.encrypted_base64 = encrypted_base64\n        self.iv = iv\n\nclass LoginData:\n    def __init__(self, username, password_client_hash):\n        self.username = username\n        self.password_client_hash = password_client_hash\n\nclass SignupData(LoginData):\n    def __init__(self, username, password_client_hash, encryption_salt):\n        super().__init__(username, password_client_hash)\n        self.encryption_salt = encryption_salt\n\nclass LoginResponse:\n    def __init__(self, username, token, encryption_salt):\n        self.username = username\n        self.token = token\n        self.encryption_salt = encryption_salt\n\nclass Credentials(LoginResponse):\n    def __init__(self, username, token, encryption_salt, jwk):\n        super().__init__(username, token, encryption_salt)\n        self.jwk = jwk\n\ndef main():\n    if len(sys.argv) < 2:\n        usage_and_exit()\n\n    command = sys.argv[1]\n\n    if command == 'signup':\n        if len(sys.argv) != 4:\n            usage_and_exit()\n        username = sys.argv[2]\n        password = sys.argv[3]\n        signup(username, password)\n    elif command == 'login':\n        if len(sys.argv) != 4:\n            usage_and_exit()\n        username = sys.argv[2]\n        password = sys.argv[3]\n        login(username, password)\n    elif command == 'create':\n        if len(sys.argv) != 3:\n            usage_and_exit()\n        text = sys.argv[2]\n        create_note(text)\n    elif command == 'get':\n        id = sys.argv[2] if len(sys.argv) == 3 else None\n        get_note(id)\n    else:\n        usage_and_exit()\n    print('Success.')\n\ndef usage_and_exit():\n    print(\"\"\"\nUsage: python3 script.py COMMAND\nAvailable commands:\n  signup USERNAME PASSWORD\n  login USERNAME PASSWORD\n  create TEXT\n  get [ID]\n\"\"\")\n    sys.exit(1)\n\ndef signup(username, password):\n    # Generate a random salt for encryption key derivation\n    salt = bytes_to_hex_string(os.urandom(16))\n    # Calculate password hash\n    hash = calc_password_hash(username, password)\n    # Prepare signup data\n    data = SignupData(username, hash, salt)\n    # Send signup request and handle response\n    res_dict = post('/api/signup', data.__dict__)\n    res = LoginResponse(res_dict['username'], res_dict['token'], res_dict['encryption_salt'])\n    # Create credentials including derived encryption key\n    credentials = create_credentials(res, password)\n    # Save credentials to file\n    write_credentials(credentials)\n\ndef login(username, password):\n    # Calculate password hash\n    hash = calc_password_hash(username, password)\n    # Prepare login data\n    data = LoginData(username, hash)\n    # Send login request and handle response\n    res_dict = post('/api/login', data.__dict__)\n    res = LoginResponse(res_dict['username'], res_dict['token'], res_dict['encryption_salt'])\n    # Create credentials including derived encryption key\n    credentials = create_credentials(res, password)\n    # Save credentials to file\n    write_credentials(credentials)\n\ndef create_note(text):\n    # Create a new note\n    note = Note(\n        str(uuid.uuid4()),\n        text,\n        datetime.utcnow().isoformat(),\n        datetime.utcnow().isoformat(),\n        1, 1, 0,\n        int(datetime.utcnow().timestamp() * 1000)\n    )\n    # Read credentials from file\n    credentials = read_credentials()\n    # Import the encryption key from credentials\n    key = import_key(credentials)\n    # Encrypt the note\n    encrypted_note = encrypt_note(note, key)\n    # Send the encrypted note to the server\n    post('/api/merge-notes', {'notes': [encrypted_note.__dict__]}, credentials)\n    print(f'Create note with ID {note.id}')\n\ndef get_note(id):\n    # Read credentials from file\n    credentials = read_credentials()\n    # Import the encryption key from credentials\n    key = import_key(credentials)\n    # Prepare list of note IDs to retrieve (or None to get all)\n    ids = [id] if id else None\n    # Retrieve encrypted notes from the server\n    encrypted_notes = post('/api/get-notes', {'ids': ids}, credentials)\n    if not encrypted_notes:\n        print('Not found')\n    else:\n        # Decrypt the received notes\n        notes = [decrypt_note(EncryptedNote(**note), key) for note in encrypted_notes]\n        # Print the decrypted notes\n        for note in notes:\n            print(json.dumps(note.__dict__, indent=2) + '\\n')\n\ndef encrypt_note(note, key):\n    # Convert the note to bytes\n    data = json.dumps(note.__dict__).encode()\n    # Generate a random initialization vector (IV)\n    iv = os.urandom(12)\n    # Create an encryptor with AES-GCM mode\n    encryptor = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()).encryptor()\n    # Encrypt the note data\n    encrypted = encryptor.update(data) + encryptor.finalize()\n    # Combine the encrypted data and tag, then encode in base64\n    encrypted_base64 = b64encode(encrypted + encryptor.tag).decode('utf-8')\n    # Return an EncryptedNote object\n    return EncryptedNote(note.id, note.modification_date, encrypted_base64, bytes_to_hex_string(iv))\n\ndef decrypt_note(encrypted_note, key):\n    # Decode the base64-encoded encrypted data\n    encrypted_bytes_with_tag = b64decode(encrypted_note.encrypted_base64)\n    # Extract the IV and tag from the encrypted data\n    iv = hex_string_to_bytes(encrypted_note.iv)\n    encrypted_bytes, tag = encrypted_bytes_with_tag[:-16], encrypted_bytes_with_tag[-16:]\n    # Create a decryptor with AES-GCM mode\n    decryptor = Cipher(algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend()).decryptor()\n    # Decrypt the note data\n    decrypted_bytes = decryptor.update(encrypted_bytes) + decryptor.finalize()\n    # Convert the decrypted bytes back to a string and parse as JSON\n    note_string = decrypted_bytes.decode('utf-8')\n    return Note(**json.loads(note_string))\n\ndef read_credentials():\n    # Read and parse the credentials from a file\n    with open('credentials.json', 'r') as file:\n        return Credentials(**json.load(file))\n\ndef write_credentials(credentials):\n    # Write the credentials to a file\n    with open('credentials.json', 'w') as file:\n        json.dump(credentials.__dict__, file, indent=2)\n    print('Wrote credentials to ./credentials.json')\n\ndef import_key(credentials):\n    # Decode the base64-encoded key from the credentials\n    key = urlsafe_b64decode(credentials.jwk['k'] + '==')\n    return key\n\ndef create_credentials(res, password):\n    # Derive a PBKDF2 key from the password and the encryption salt\n    kdf = PBKDF2HMAC(\n        algorithm=hashes.SHA256(),\n        length=32,\n        salt=bytes.fromhex(res.encryption_salt),\n        iterations=100000,\n        backend=default_backend()\n    )\n    key = kdf.derive(password.encode())\n    # Create a JSON Web Key (JWK) from the derived key\n    jwk = {'key_ops': ['encrypt', 'decrypt'], 'ext': True, 'kty': 'oct', 'k': urlsafe_b64encode(key).decode('utf-8').rstrip('=')}\n    # Return credentials including the JWK\n    return Credentials(res.username, res.token, res.encryption_salt, jwk)\n\ndef post(pathname, body, credentials=None):\n    # Construct the full URL with the optional token as a query parameter\n    url = f'{BASE_URL}{pathname}'\n    if credentials:\n        url += f'?token={credentials.token}'\n    # Send a POST request with the provided body\n    headers = {'Content-Type': 'application/json'}\n    response = requests.post(url, headers=headers, json=body)\n    response.raise_for_status()\n    return response.json()\n\ndef calc_password_hash(username, password):\n    # Calculate a SHA-256 hash of the username, password, and a static random number\n    text = f'{username}{password}32261572990560219427182644435912532'\n    hash_buf = hashlib.sha256(text.encode()).digest()\n    return bytes_to_hex_string(hash_buf)\n\ndef bytes_to_hex_string(bytes):\n    # Convert a byte array to a hex string\n    return ''.join(f'{byte:02x}' for byte in bytes)\n\ndef hex_string_to_bytes(hex_string):\n    # Convert a hex string to a byte array\n    if len(hex_string) % 2 != 0:\n        raise ValueError('Invalid hex string')\n    return bytes.fromhex(hex_string)\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/example.ts",
    "content": "import { webcrypto } from 'node:crypto';\nimport fs from 'node:fs';\n\ntype Note = {\n  // UUID version 4\n  id: string;\n\n  // Deleted notes have null text\n  text: string | null;\n\n  // ISO 8601 format\n  creation_date: string;\n\n  // ISO 8601 format\n  modification_date: string;\n\n  // 0 means deleted, 1 means not deleted\n  not_deleted: number;\n\n  // 0 means archived, 1 means not archived\n  not_archived: number;\n\n  // 0 means not pinned, 1 means pinned\n  pinned: number;\n\n  // A higher number means higher on the list\n  // Usually, by default it's milliseconds since the epoch\n  order: number;\n};\n\ntype EncryptedNote = {\n  // UUID version 4\n  id: string;\n\n  // ISO 8601 format\n  modification_date: string;\n\n  // The encrypted Note in base64 format\n  encrypted_base64: string;\n\n  // Initial vector, a random number, that was used for encrypting this specific note\n  iv: string;\n};\n\ntype LoginData = {\n  username: string;\n  password_client_hash: string;\n};\n\ntype SignupData = {\n  username: string;\n  password_client_hash: string;\n  encryption_salt: string;\n};\n\ntype LoginResponse = {\n  username: string;\n  token: string;\n  encryption_salt: string;\n};\n\n// In addition to LoginResponse, we want to locally store the CryptoKey which is derived from\n// the encryption salt and the raw password during login/signup and used for encryption/decryption.\n// However, since CryptoKey is not directly serializable, we convert it to JsonWebKey and use\n// importKey() to convert back later.\ntype Credentials = LoginResponse & { jwk: webcrypto.JsonWebKey };\n\nconst BASE_URL = 'https://unforget.computing-den.com';\n\nasync function main() {\n  switch (process.argv[2]) {\n    case 'signup': {\n      const username = process.argv[3];\n      const password = process.argv[4];\n      if (!username || !password) usageAndExit();\n\n      await signup(username, password);\n      break;\n    }\n    case 'login': {\n      const username = process.argv[3];\n      const password = process.argv[4];\n      if (!username || !password) usageAndExit();\n\n      await login(username, password);\n      break;\n    }\n    case 'create': {\n      const text = process.argv[3];\n      if (!text) usageAndExit();\n\n      await createNote(text);\n      break;\n    }\n    case 'get': {\n      const id = process.argv[3];\n\n      await getNote(id);\n      break;\n    }\n    default:\n      usageAndExit();\n  }\n  console.log('Success.');\n}\n\nfunction usageAndExit() {\n  console.error(`\nUsage: npx tsx example.ts COMMAND\nAvailable commands:\n  singup USERNAME PASSWORD\n  login USERNAME PASSWORD\n  create TEXT\n  get [ID]\n`);\n  process.exit(1);\n}\n\nasync function signup(username: string, password: string) {\n  const salt = bytesToHexString(webcrypto.getRandomValues(new Uint8Array(16)));\n  const hash = await calcPasswordHash(username, password);\n  const data: SignupData = { username, password_client_hash: hash, encryption_salt: salt };\n  const res = await post<LoginResponse>('/api/signup', data);\n  const credentials = await createCredentials(res, password);\n  writeCredentials(credentials);\n}\n\nasync function login(username: string, password: string) {\n  const hash = await calcPasswordHash(username, password);\n  const data: LoginData = { username, password_client_hash: hash };\n  const res = await post<LoginResponse>('/api/login', data);\n  const credentials = await createCredentials(res, password);\n  writeCredentials(credentials);\n}\n\nasync function createNote(text: string) {\n  const note: Note = {\n    id: webcrypto.randomUUID(),\n    text,\n    creation_date: new Date().toISOString(),\n    modification_date: new Date().toISOString(),\n    not_deleted: 1,\n    not_archived: 1,\n    pinned: 0,\n    order: Date.now(),\n  };\n\n  // Read the credentials and convert the key from JsonWebKey back to CryptoKey.\n  const credentials = readCredentials();\n  const key = await importKey(credentials);\n\n  const encryptedNote = await encryptNote(note, key);\n  await post(`/api/merge-notes`, { notes: [encryptedNote] }, credentials);\n  console.log(`Created note with ID ${note.id}`);\n}\n\nasync function getNote(id?: string) {\n  // Read the credentials and convert the key from JsonWebKey back to CryptoKey.\n  const credentials = readCredentials();\n  const key = await importKey(credentials);\n\n  // ids: [] would return no notes. ids: undefined or null would return everything.\n  const ids = id ? [id] : null;\n  const encryptedNotes = await post<EncryptedNote[]>(`/api/get-notes`, { ids }, credentials);\n\n  if (encryptedNotes.length === 0) {\n    console.log('Not found');\n  } else {\n    // Decrypt the received notes using the key.\n    const notes = await Promise.all(encryptedNotes.map(x => decryptNote(x, key)));\n    // Log to console.\n    for (const note of notes) console.log(JSON.stringify(note, null, 2) + '\\n');\n  }\n}\n\nasync function encryptNote(note: Note, key: webcrypto.CryptoKey): Promise<EncryptedNote> {\n  // Encode the string to bytes.\n  const data = new TextEncoder().encode(JSON.stringify(note));\n\n  // Generate the initial vector (iv).\n  const iv = webcrypto.getRandomValues(new Uint8Array(12));\n\n  // Encrypt the bytes using the iv and the given key.\n  const encrypted = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);\n\n  // Encode as base64 to easily store in JSON.\n  const encryptedBase64 = Buffer.from(encrypted).toString('base64');\n\n  // Create the EncryptedNote object.\n  return {\n    id: note.id,\n    modification_date: note.modification_date,\n    encrypted_base64: encryptedBase64,\n    iv: bytesToHexString(iv),\n  };\n}\n\nasync function decryptNote(encryptedNote: EncryptedNote, key: webcrypto.CryptoKey): Promise<Note> {\n  // Decode the base64 string to bytes.\n  const encryptedBytes = Buffer.from(encryptedNote.encrypted_base64, 'base64');\n\n  // Decrypt the bytes using note's initial vector (iv) and the given key.\n  const iv = hexStringToBytes(encryptedNote.iv);\n  const decryptedBytes = await webcrypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes);\n\n  // Decode the decrypted bytes into string.\n  const noteString = new TextDecoder().decode(decryptedBytes);\n\n  // Parse the string to get the note JSON.\n  return JSON.parse(noteString);\n}\n\n/**\n * Read the credentials from ./credentials.json\n */\nfunction readCredentials(): Credentials {\n  return JSON.parse(fs.readFileSync('./credentials.json', 'utf8'));\n}\n\n/**\n * Write the credentials to ./credentials.json.\n */\nfunction writeCredentials(credentials: Credentials) {\n  fs.writeFileSync('credentials.json', JSON.stringify(credentials, null, 2));\n  console.log('Wrote credentials to ./credentials.json');\n}\n\n/**\n * Converts the JsonWebKey (credentials.jwk) which was exported from CryptoKey back to CryptoKey so\n * that it can be used for encrypting and decrypting notes.\n */\nasync function importKey(credentials: Credentials): Promise<CryptoKey> {\n  return webcrypto.subtle.importKey('jwk', credentials.jwk, 'AES-GCM', true, ['encrypt', 'decrypt']);\n}\n\n/**\n * It derives a PBKDF2 CryptoKey from the password and the res.encryption_salt for encrypting and decrypting notes.\n * The CryptoKey is then exported to JsonWebKey so that we can serialize it and store it in credentials.json.\n * Use importKey() to convert back to CryptoKey.\n */\nasync function createCredentials(res: LoginResponse, password: string): Promise<Credentials> {\n  const keyData = new TextEncoder().encode(password);\n  const keyMaterial = await webcrypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']);\n\n  const saltBuf = hexStringToBytes(res.encryption_salt);\n  const key = await webcrypto.subtle.deriveKey(\n    {\n      name: 'PBKDF2',\n      salt: saltBuf,\n      iterations: 100000,\n      hash: 'SHA-256',\n    },\n    keyMaterial,\n    { name: 'AES-GCM', length: 256 },\n    true,\n    ['encrypt', 'decrypt'],\n  );\n\n  const jwk = await webcrypto.subtle.exportKey('jwk', key);\n  return { ...res, jwk };\n}\n\n/**\n * Send a POST request to BASE_URL and parse the resopnse as JSON.\n */\nasync function post<T>(pathname: string, body?: any, credentials?: Credentials): Promise<T> {\n  const query = credentials ? `?token=${credentials.token}` : '';\n  const url = `${BASE_URL}${pathname}${query}`;\n  const res = await fetch(url, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: body && JSON.stringify(body),\n  });\n  if (!res.ok) throw new Error(await res.text());\n  return res.json();\n}\n\n/**\n * The password hash is derived from the username, password, and a specific static random number.\n * It is important to use the exact same method for calculating the hash if you wish the\n * credentials to work with the official unforget app.\n */\nasync function calcPasswordHash(username: string, password: string): Promise<string> {\n  const text = username + password + '32261572990560219427182644435912532';\n  const encoder = new TextEncoder();\n  const textBuf = encoder.encode(text);\n  const hashBuf = await webcrypto.subtle.digest('SHA-256', textBuf);\n  return bytesToHexString(new Uint8Array(hashBuf));\n}\n\n/**\n * bytesToHexString(Uint8Array.from([1, 2, 3, 10, 11, 12])) //=> '0102030a0b0c'\n */\nfunction bytesToHexString(bytes: Uint8Array): string {\n  return Array.from(bytes)\n    .map(byte => byte.toString(16).padStart(2, '0'))\n    .join('');\n}\n\n/**\n * hexStringToBytes('0102030a0b0c') //=> Uint8Array(6) [ 1, 2, 3, 10, 11, 12 ]\n */\nfunction hexStringToBytes(str: string): Uint8Array {\n  if (str.length % 2) throw new Error('hexStringToBytes invalid string');\n  const bytes = new Uint8Array(str.length / 2);\n  for (let i = 0; i < str.length; i += 2) {\n    bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);\n  }\n  return bytes;\n}\n\nmain();\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"unforget\",\n  \"title\": \"Unforget\",\n  \"version\": \"1.0.0\",\n  \"description\": \"A minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js)\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"node --enable-source-maps dist/server/index.js\",\n    \"clean\": \"barefront clean\",\n    \"build\": \"barefront build\",\n    \"dev\": \"barefront dev\",\n    \"init-server\": \"barefront init-server\",\n    \"clean-server\": \"barefront clean-server\",\n    \"deploy\": \"barefront deploy\"\n  },\n  \"exports\": {\n    \"./*\": \"./dist/*\"\n  },\n  \"barefront\": {\n    \"library\": false\n  },\n  \"author\": \"Sean Shirazi <sean@computing-den.com>\",\n  \"keywords\": [\n    \"PWA\",\n    \"Progressive Web App\",\n    \"React\",\n    \"SaaS\",\n    \"note-taking\",\n    \"note\",\n    \"todo\",\n    \"minimalist\",\n    \"minimal\",\n    \"simple\",\n    \"E2EE\",\n    \"privacy\",\n    \"offline\",\n    \"encrypted\"\n  ],\n  \"license\": \"MIT\",\n  \"homepage\": \"https://unforget.computing-den.com/demo\",\n  \"repository\": \"github:computing-den/unforget\",\n  \"engines\": {\n    \"node\": \">=18.19.0\"\n  },\n  \"devDependencies\": {\n    \"@types/better-sqlite3\": \"^7.6.9\",\n    \"@types/body-parser\": \"^1.19.5\",\n    \"@types/cookie-parser\": \"^1.4.6\",\n    \"@types/express\": \"^4.17.21\",\n    \"@types/lodash\": \"^4.14.202\",\n    \"@types/node\": \"^20.11.20\",\n    \"@types/react\": \"^18.2.57\",\n    \"@types/react-dom\": \"^18.2.19\",\n    \"@types/uuid\": \"^9.0.8\",\n    \"barefront\": \"2.14.0\",\n    \"esbuild\": \"^0.25.8\",\n    \"typescript\": \"^5.9.2\"\n  },\n  \"dependencies\": {\n    \"better-sqlite3\": \"^9.4.3\",\n    \"body-parser\": \"^1.20.2\",\n    \"cookie-parser\": \"^1.4.6\",\n    \"dotenv\": \"^16.4.5\",\n    \"express\": \"^4.18.2\",\n    \"hast-util-to-html\": \"^9.0.1\",\n    \"immer\": \"^10.0.3\",\n    \"lodash\": \"^4.17.21\",\n    \"mdast-util-from-markdown\": \"^2.0.0\",\n    \"mdast-util-gfm\": \"^3.0.0\",\n    \"mdast-util-newline-to-break\": \"^2.0.0\",\n    \"mdast-util-to-hast\": \"^13.1.0\",\n    \"micromark-extension-gfm\": \"^3.0.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"unist-util-visit\": \"^5.0.0\",\n    \"unist-util-visit-parents\": \"^6.0.1\",\n    \"unzipit\": \"^1.4.3\",\n    \"uuid\": \"^9.0.1\"\n  }\n}\n"
  },
  {
    "path": "public/manifest.json",
    "content": "{\n  \"name\": \"Unforget\",\n  \"short_name\": \"Unforget\",\n  \"description\": \"Never forget a thing.\",\n  \"theme_color\": \"#448199\",\n  \"background_color\": \"#448199\",\n  \"display\": \"standalone\",\n  \"orientation\": \"portrait\",\n  \"scope\": \"/\",\n  \"start_url\": \"/\",\n  \"icons\": [\n    {\n      \"src\": \"/icon-256x256.png\",\n      \"sizes\": \"256x256\",\n      \"type\": \"image/png\"\n    }\n  ],\n  \"splash_pages\": null\n}\n"
  },
  {
    "path": "scripts/script.ts",
    "content": "let hello: string = 'Hello';\n\nconsole.log(hello);\n"
  },
  {
    "path": "scripts/tsconfig.json",
    "content": "{\n  \"extends\": \"@tsconfig/node18/tsconfig.json\"\n}\n"
  },
  {
    "path": "src/client/AboutPage.tsx",
    "content": "import React from 'react';\nimport * as appStore from './appStore.jsx';\nimport { createNewNote, CACHE_VERSION } from '../common/util.js';\nimport { PageLayout, PageHeader, PageBody } from './PageLayout.jsx';\nimport { Notes } from './Notes.jsx';\nimport _ from 'lodash';\nimport aboutMd from './notes/about.md';\n\nconst technicalDetails = `\\n\\n# Technical details\\n\\nCache version: ${CACHE_VERSION}`;\nconst aboutNote = createNewNote(aboutMd + technicalDetails);\n\nfunction AboutPage() {\n  const app = appStore.use();\n\n  return (\n    <PageLayout>\n      <PageHeader title=\"/ about\" compact={!app.user} />\n      <PageBody>\n        <div className=\"page\">\n          <Notes notes={[aboutNote]} readonly />\n        </div>\n      </PageBody>\n    </PageLayout>\n  );\n}\n\nexport default AboutPage;\n"
  },
  {
    "path": "src/client/App.tsx",
    "content": "import { Router, Route, useRouter } from './router.jsx';\nimport React, { useEffect } from 'react';\nimport * as appStore from './appStore.js';\nimport log from './logger.js';\nimport LoginPage from './LoginPage.jsx';\nimport AboutPage from './AboutPage.jsx';\nimport DemoPage from './DemoPage.jsx';\nimport { NotesPage, notesPageLoader } from './NotesPage.jsx';\nimport { NotePage, notePageLoader } from './NotePage.jsx';\nimport { ImportPage } from './ImportPage.jsx';\nimport { ExportPage } from './ExportPage.jsx';\nimport Notifications from './Notifications.jsx';\nimport _ from 'lodash';\n\nexport default function App() {\n  const routes: Route[] = [\n    {\n      path: '/login',\n      element: <LoginPage />,\n    },\n    {\n      path: '/about',\n      element: <AboutPage />,\n    },\n    {\n      path: '/demo',\n      element: <DemoPage />,\n    },\n    {\n      path: '/n/:noteId',\n      element: ({ params }) => (\n        <Auth>\n          <NotePage key={params.noteId as string} />\n        </Auth>\n      ),\n      loader: notePageLoader,\n    },\n    {\n      path: '/import',\n      element: (\n        <Auth>\n          <ImportPage key=\"/import\" />\n        </Auth>\n      ),\n    },\n    {\n      path: '/export',\n      element: (\n        <Auth>\n          <ExportPage key=\"/export\" />\n        </Auth>\n      ),\n    },\n    {\n      path: '/archive',\n      element: (\n        <Auth>\n          <NotesPage key=\"/archive\" />\n        </Auth>\n      ),\n      loader: notesPageLoader,\n    },\n    {\n      path: '/',\n      element: (\n        <Auth>\n          <NotesPage key=\"/\" />\n        </Auth>\n      ),\n      loader: notesPageLoader,\n    },\n  ];\n\n  return (\n    <>\n      <Router routes={routes} fallback={<Fallback />} />\n      <Notifications />\n    </>\n  );\n}\n\nfunction Fallback() {\n  return null;\n}\n\nfunction Auth(props: { children: React.ReactNode }) {\n  const router = useRouter();\n  const app = appStore.use();\n\n  useEffect(() => {\n    if (!app.user) {\n      let params = '';\n      if (router.pathname !== '/') {\n        params = new URLSearchParams({ from: router.pathname }).toString();\n      }\n      const url = '/login' + (params ? `?${params}` : '');\n      history.replaceState(null, '', url);\n    }\n  }, [app.user, router]);\n\n  return app.user ? props.children : null;\n}\n"
  },
  {
    "path": "src/client/DemoPage.tsx",
    "content": "import * as appStore from './appStore.js';\nimport * as actions from './appStoreActions.jsx';\nimport _ from 'lodash';\n\nfunction DemoPage() {\n  const user = appStore.get().user;\n  if (!user || user.username === 'demo') {\n    actions.setUpDemo().then(() => history.replaceState(null, '', '/'));\n  } else {\n    history.replaceState(null, '', '/');\n  }\n\n  return null;\n}\n\nexport default DemoPage;\n"
  },
  {
    "path": "src/client/Editor.css",
    "content": "textarea.editor {\n  /* background: #eee; */\n  padding: 1rem 1rem;\n  /* margin: 5rem; */\n  /* width: 100px; */\n  font-family: inherit;\n  /* font-variant-ligatures: no-common-ligatures; */\n  white-space: pre-wrap;\n  word-break: break-word;\n  word-wrap: break-word;\n  /* line-height: var(--note-line-height); */\n  resize: vertical;\n  font-family: monospace;\n}\n"
  },
  {
    "path": "src/client/Editor.tsx",
    "content": "import log from './logger.js';\nimport React, { useState, useLayoutEffect, useRef, forwardRef, useImperativeHandle } from 'react';\nimport type * as t from '../common/types.js';\nimport * as cutil from '../common/util.js';\nimport * as md from '../common/mdFns.js';\nimport { MenuItem } from './Menu.js';\nimport { useClickWithoutDrag } from './hooks.jsx';\nimport _ from 'lodash';\nimport { v4 as uuid } from 'uuid';\n\ntype EditorProps = {\n  value: string;\n  onChange: (value: string) => any;\n  id?: string;\n  className?: string;\n  placeholder?: string;\n  // autoFocus?: boolean;\n  readOnly?: boolean;\n  onFocus?: React.FocusEventHandler<HTMLTextAreaElement>;\n  onBlur?: React.FocusEventHandler<HTMLTextAreaElement>;\n  autoExpand?: boolean;\n  // onConfirm: () => any;\n  // onDelete: () => any;\n  // onTogglePinned: () => any;\n};\n\nexport type EditorContext = {\n  cycleListStyle: () => any;\n  focus: () => any;\n  textareaRef: React.RefObject<HTMLTextAreaElement>;\n};\n\ntype Selection = { start: number; end: number; direction: 'forward' | 'backward' | 'none' };\n\nexport const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorContext>) {\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  // const [selection, setSelection] = useState<Selection>({ start: 0, end: 0, direction: 'forward' });\n  // const [lastSelection, setLastSelection] = useState<Selection | undefined>();\n\n  function replaceText(deleteStart: number, deleteEnd: number, text: string = '') {\n    const textarea = textareaRef.current!;\n    const currentValue = textarea.value;\n    const before = currentValue.slice(0, deleteStart);\n    const after = currentValue.slice(deleteEnd);\n    const newCursor = deleteStart + text.length;\n\n    props.onChange(before + text + after);\n\n    requestAnimationFrame(() => {\n      textarea.setSelectionRange(newCursor, newCursor);\n    });\n  }\n\n  function replaceListItemPrefix(listItem: md.ListItem, newListItem: md.ListItem, lineRange: md.Range) {\n    const linePrefix = md.stringifyListItemPrefix(listItem);\n    const newLinePrefix = md.stringifyListItemPrefix(newListItem);\n    replaceText(lineRange.start, lineRange.start + linePrefix.length, newLinePrefix);\n  }\n\n  function cycleListStyle() {\n    const textarea = textareaRef.current!;\n    const text = textarea.value;\n    // If there's not lastSelection, assume end of text\n    const i = textarea.selectionStart;\n    const lineRange = md.getLineRangeAt(text, i);\n    const line = md.getLine(text, lineRange);\n    const listItem = md.parseListItem(line);\n\n    // console.log('lastSelection', lastSelection);\n\n    // unstyled -> checkbox -> bulletpoint ...\n    if (listItem.checkbox) {\n      replaceListItemPrefix(listItem, md.removeListItemCheckbox(listItem), lineRange);\n    } else if (listItem.type) {\n      replaceListItemPrefix(listItem, md.removeListItemType(listItem), lineRange);\n      // } else if (!lastSelection) {\n      //   replaceText(text.length, text.length, text.length > 0 ? '\\n- [ ] ' : '- [ ] ');\n      //   textarea.setSelectionRange(textarea.value.length, textarea.value.length);\n    } else {\n      replaceListItemPrefix(listItem, md.addListItemCheckbox(listItem), lineRange);\n    }\n\n    textarea.focus();\n    // If there were no lastSelection, move cursor to the end.\n    // if (!lastSelection) {\n    //   textarea.focus();\n    //   textarea.setSelectionRange(textarea.value.length, textarea.value.length);\n    // }\n  }\n\n  function focus() {\n    textareaRef.current!.focus();\n  }\n\n  useImperativeHandle<EditorContext, EditorContext>(ref, () => ({ cycleListStyle, focus, textareaRef }), [\n    cycleListStyle,\n    focus,\n    textareaRef,\n  ]);\n\n  function changeCb() {\n    props.onChange(textareaRef.current!.value);\n  }\n\n  function keyDownCb(e: React.KeyboardEvent) {\n    const textarea = textareaRef.current!;\n\n    if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {\n      // textarea.focus();\n      const text = textarea.value;\n      const i = textarea.selectionStart;\n      const lineRange = md.getLineRangeAt(text, i);\n      const line = md.getLine(text, lineRange);\n      const listItem = md.parseListItem(line);\n      const listItemPrefix = md.stringifyListItemPrefix(listItem);\n\n      // Ignore if cursor is before the line prefix\n      if (i < lineRange.start + listItemPrefix.length) return;\n\n      if (!listItemPrefix) return;\n\n      e.preventDefault();\n      if (listItem.content) {\n        // Increment list item number and set empty checkbox.\n        let newListItem = md.incrementListItemNumber(listItem);\n        if (listItem.checkbox) {\n          newListItem = md.setListItemCheckbox(newListItem, false);\n        }\n\n        // Delete whitespace and insert the prefix.\n        const afterWhitespace = md.skipWhitespaceSameLine(text, i);\n        const before = text.slice(0, i);\n        const after = text.slice(afterWhitespace);\n        const insert = '\\n' + md.stringifyListItemPrefix(newListItem);\n        const newCursor = before.length + insert.length;\n\n        props.onChange(before + insert + after);\n        requestAnimationFrame(() => {\n          textarea.setSelectionRange(newCursor, newCursor);\n        });\n      } else {\n        // Pressing enter on a line with prefix and empty content will clear the prefix.\n        const before = text.slice(0, lineRange.start);\n        const after = text.slice(lineRange.end);\n        const newText = before + after;\n        const newCursor = lineRange.start;\n\n        props.onChange(newText);\n        requestAnimationFrame(() => {\n          textarea.setSelectionRange(newCursor, newCursor);\n        });\n      }\n    }\n  }\n\n  // function selectCb() {\n  //   const textarea = textareaRef.current!;\n  //   // setLastSelection(selection);\n  //   setSelection({\n  //     start: textarea.selectionStart,\n  //     end: textarea.selectionEnd,\n  //     direction: textarea.selectionDirection,\n  //   });\n  // }\n\n  function clickCb(e: React.MouseEvent) {\n    const textarea = textareaRef.current!;\n    const text = textarea.value;\n    const i = textarea.selectionDirection === 'forward' ? textarea.selectionEnd : textarea.selectionStart;\n    const lineRange = md.getLineRangeAt(text, i);\n    const line = md.getLine(text, lineRange);\n    const listItem = md.parseListItem(line);\n\n    if (!md.isCursorOnCheckbox(listItem, i - lineRange.start)) return;\n\n    const newListItem = md.toggleListItemCheckbox(listItem);\n    const checkboxRange = md.getListItemCheckboxRange(listItem);\n\n    const startPos = lineRange.start + checkboxRange.start;\n    const endPos = lineRange.start + checkboxRange.end;\n\n    const newText = text.slice(0, startPos) + newListItem.checkbox + text.slice(endPos);\n    props.onChange(newText);\n\n    requestAnimationFrame(() => {\n      const newCursor = startPos + newListItem.checkbox.length;\n      textarea.setSelectionRange(newCursor, newCursor);\n    });\n  }\n\n  const { onClick, onMouseDown } = useClickWithoutDrag(clickCb);\n\n  function pasteCb(e: React.ClipboardEvent<HTMLTextAreaElement>) {\n    const textarea = textareaRef.current!;\n    const pasteData = e.clipboardData.getData('text/plain');\n\n    // const start = textarea.selectionDirection === 'forward' ? textarea.selectionEnd : textarea.selectionStart;\n    const text = textarea.value;\n\n    const selectionStart = textarea.selectionStart;\n    const selectionEnd = textarea.selectionEnd;\n\n    const lineRange = md.getLineRangeAt(text, selectionStart);\n    const line = md.getLine(text, lineRange);\n    const listItem = md.parseListItem(line);\n\n    if (!listItem.type) return;\n\n    e.preventDefault();\n\n    const pasteLines = pasteData.split(/\\r?\\n/g);\n    const pasteListItems = pasteLines.map(md.parseListItem);\n    pasteListItems[0].content = listItem.content + pasteListItems[0].content;\n\n    // const linePrefix = md.stringifyListItemPrefix(listItem);\n\n    const newLineItems: md.ListItem[] = [];\n    let emptyListItem = { ...listItem, content: '' };\n    for (const pasteListItem of pasteListItems) {\n      emptyListItem = md.incrementListItemNumber(emptyListItem);\n      newLineItems.push({\n        ...emptyListItem,\n        checkbox: pasteListItem.checkbox || emptyListItem.checkbox,\n        content: pasteListItem.content,\n      });\n    }\n\n    const newText = newLineItems.map(md.stringifyListItem).join('\\n');\n    replaceText(lineRange.start, selectionEnd, newText);\n  }\n\n  useLayoutEffect(() => {\n    if (props.autoExpand) {\n      const editor = textareaRef.current!;\n      const style = window.getComputedStyle(editor);\n      const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);\n      editor.style.height = '0'; // Shrink it first.\n      editor.style.height = `${editor.scrollHeight - padding}px`;\n    }\n  }, [props.value, props.autoExpand]);\n\n  return (\n    <textarea\n      id={props.id}\n      ref={textareaRef}\n      className={`editor text-input ${props.className || ''}`}\n      onMouseDown={onMouseDown}\n      onClick={onClick}\n      onFocus={props.onFocus}\n      onBlur={props.onBlur}\n      onChange={changeCb}\n      onKeyDown={keyDownCb}\n      value={props.value}\n      placeholder={props.placeholder}\n      // autoFocus={props.autoFocus}\n      readOnly={props.readOnly}\n      // onSelect={selectCb}\n      onPaste={pasteCb}\n    />\n  );\n});\n"
  },
  {
    "path": "src/client/ExportPage.tsx",
    "content": "import React, { useCallback, useState, useEffect, useRef } from 'react';\nimport type * as t from '../common/types.js';\nimport { createNewNote } from '../common/util.js';\nimport * as actions from './appStoreActions.jsx';\nimport * as storage from './storage.js';\nimport { PageLayout, PageHeader, PageBody, PageAction } from './PageLayout.jsx';\nimport { Notes, Note } from './Notes.jsx';\nimport _ from 'lodash';\nimport exportMd from './notes/export.md';\n\nconst exportNote = createNewNote(exportMd);\n\nexport function ExportPage() {\n  async function hashLinkClicked(hash: string) {\n    try {\n      const notes = await storage.getAllNotes();\n      offerDownload('notes.json', JSON.stringify(notes, null, 2));\n    } catch (error) {\n      actions.gotError(error as Error);\n    }\n  }\n\n  return (\n    <PageLayout>\n      <PageHeader title=\"/ export\" />\n      <PageBody>\n        <div className=\"page\">\n          <Notes notes={[exportNote]} readonly onHashLinkClick={hashLinkClicked} />\n        </div>\n      </PageBody>\n    </PageLayout>\n  );\n}\n\nfunction offerDownload(filename: string, text: string) {\n  var element = document.createElement('a');\n  element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));\n  element.setAttribute('download', filename);\n  element.style.display = 'none';\n  document.body.appendChild(element);\n  element.click();\n  document.body.removeChild(element);\n}\n"
  },
  {
    "path": "src/client/ImportPage.tsx",
    "content": "import React, { useState } from 'react';\nimport type * as t from '../common/types.js';\nimport { createNewNote, assert } from '../common/util.js';\nimport * as actions from './appStoreActions.jsx';\nimport { PageLayout, PageHeader, PageBody } from './PageLayout.jsx';\nimport { Notes } from './Notes.jsx';\nimport _ from 'lodash';\nimport log from './logger.js';\nimport { unzip } from 'unzipit';\nimport { v4 as uuid } from 'uuid';\nimport importMd from './notes/import.md';\n\nconst initialImportNote = createNewNote(importMd);\n\nconst importers = {\n  '#keep': importKeep,\n  '#apple': importApple,\n  '#standard': importStandard,\n  '#unforget': importUnforget,\n};\n\ntype ImportKeys = keyof typeof importers;\n\nexport function ImportPage() {\n  // const app = appStore.use();\n\n  // const [file, setFile] = useState<File>();\n  const [importing, setImporting] = useState(false);\n  const [importType, setImportType] = useState<ImportKeys>();\n  const [note, setNote] = useState(initialImportNote);\n\n  async function importCb(e: React.ChangeEvent<HTMLInputElement>) {\n    try {\n      const newFile = e.target.files?.[0];\n      // setFile(newFile);\n      if (!newFile) return;\n      setImporting(true);\n      assert(importType, 'Unknown import type');\n      const notes = await importers[importType](newFile, note);\n\n      if (notes.length) {\n        await actions.saveNotes(notes, { message: `Imported ${notes.length} notes`, immediateSync: true });\n      } else {\n        actions.showMessage('No notes were found');\n      }\n\n      window.history.replaceState(null, '', '/');\n    } catch (error) {\n      actions.gotError(error as Error);\n    } finally {\n      setImporting(false);\n    }\n  }\n\n  function hashLinkClicked(hash: string) {\n    setImportType(hash as ImportKeys);\n    (document.querySelector('input[type=\"file\"]') as HTMLInputElement).click();\n  }\n\n  return (\n    <PageLayout>\n      <PageHeader title=\"/ import\" />\n      <PageBody>\n        <div className=\"page\">\n          {!importing && <Notes notes={[note]} onHashLinkClick={hashLinkClicked} onNoteChange={setNote} />}\n          {!importing && (\n            <input\n              type=\"file\"\n              name=\"file\"\n              accept=\"application/zip, application/json\"\n              onChange={importCb}\n              style={{ display: 'none' }}\n            />\n          )}\n          {importing && <h2 className=\"page-message\">Please wait ...</h2>}\n          {/*\n          <div className=\"-content\">\n            <h1>Google Keep</h1>\n            <p>\n              Go to{' '}\n              <a target=\"_blank\" href=\"https://takeout.google.com/\">\n                Google Takeout\n              </a>\n              .\n            </p>\n            <p>Select only Keep's data for export.</p>\n            <p>Export it as a zip file.</p>\n            <p className=\"wait-for-download\">It'll be ready for download in a few minutes.</p>\n            <p className=\"on-device\">Your data will stay on your device.</p>\n\n            <button className=\"import primary\" onClick={importCb}>\n              Import notes from zip file\n            </button>\n            <input type=\"file\" name=\"file\" accept=\"application/zip\" onChange={e => setFile(e.target.files?.[0])} />\n\n        </div>\n        */}\n        </div>\n      </PageBody>\n    </PageLayout>\n  );\n}\n\nasync function importUnforget(jsonFile: File): Promise<t.Note[]> {\n  return JSON.parse(await jsonFile.text());\n}\n\nasync function importKeep(zipFile: File, note: t.Note): Promise<t.Note[]> {\n  const optIncludeTags = hasOption(note.text!, 'include labels as tags');\n\n  const { entries } = await unzip(zipFile);\n\n  const regexp = /^Takeout\\/Keep\\/[^\\/]+\\.json$/;\n  const jsonEntries = Object.values(entries).filter(entry => regexp.test(entry.name));\n\n  const notes: t.Note[] = [];\n  for (const entry of jsonEntries) {\n    const entryText = await entry.text();\n    const json = JSON.parse(entryText);\n    let errorMessage: string | undefined;\n    if ((errorMessage = validateGoogleKeepJson(json))) {\n      log(entryText);\n      throw new Error(`Found a note with unknown format: ${errorMessage}`);\n    }\n    if (json.isTrashed) continue;\n\n    const segments = [\n      json.title,\n      json.textContent,\n      (json.listContent || [])\n        .map((item: any) => (item.isChecked ? `- [x] ${item.text || ''}` : `- [ ] ${item.text || ''}`))\n        .join('\\n'),\n      optIncludeTags && json.labels?.map((x: any) => '#' + x.name).join(' '),\n    ];\n    const text = segments.filter(Boolean).join('\\n\\n');\n\n    notes.push({\n      id: uuid(),\n      text,\n      creation_date: new Date(Math.floor(json.createdTimestampUsec / 1000)).toISOString(),\n      modification_date: new Date(Math.floor(json.userEditedTimestampUsec / 1000)).toISOString(),\n      order: Math.floor(json.createdTimestampUsec / 1000),\n      not_deleted: 1,\n      not_archived: json.isArchived ? 0 : 1,\n      pinned: json.isPinned ? 1 : 0,\n    });\n  }\n\n  return notes;\n}\n\nfunction validateGoogleKeepJson(json: any): string | undefined {\n  if (!('createdTimestampUsec' in json)) return 'Missing createdTimestampUsec';\n  if (!('userEditedTimestampUsec' in json)) return 'Missing userEditedTimestampUsec';\n\n  // NOTE: some notes have neither listContent nor textContent. So be more lenient.\n  // if (!('isTrashed' in json)) return 'Missing isTrashed';\n  // if (!('isPinned' in json)) return 'Missing isPinned';\n  // if (!('isArchived' in json)) return 'Missing isArchived';\n  // if (!('listContent' in json) && !('textContent' in json)) return 'Missing listContent and textContent';\n  // if (!('title' in json) && !('title' in json)) return 'Missing title';\n}\n\nasync function importApple(zipFile: File, note: t.Note): Promise<t.Note[]> {\n  const optIncludeTags = hasOption(note.text!, 'include folder names as tags');\n\n  const { entries } = await unzip(zipFile);\n  const regexp = /^.*-(\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ)\\.txt$/;\n  const notes: t.Note[] = [];\n\n  for (const entry of Object.values(entries)) {\n    const parts = entry.name.split('/');\n    if (parts.includes('Recently Deleted')) continue;\n\n    const match = parts.at(-1)?.match(regexp);\n    if (!match) continue;\n\n    const date = new Date(match[1]);\n\n    let text = await entry.text();\n    if (optIncludeTags) {\n      const tags = parts\n        .slice(1, -2)\n        .map(x => '#' + x.replace(' ', '-'))\n        .join(' ');\n      text += '\\n\\n' + tags;\n    }\n\n    notes.push({\n      id: uuid(),\n      text,\n      creation_date: date.toISOString(),\n      modification_date: date.toISOString(),\n      order: date.valueOf(),\n      not_deleted: 1,\n      not_archived: 1,\n      pinned: 0,\n    });\n  }\n  return notes;\n}\n\nasync function importStandard(zipFile: File): Promise<t.Note[]> {\n  const { entries } = await unzip(zipFile);\n  const regexp = /^([^\\/]+)\\.txt$/;\n  const notes: t.Note[] = [];\n  const startMs = Date.now();\n\n  for (const [i, entry] of Object.values(entries).entries()) {\n    const match = entry.name.match(regexp);\n    if (!match) continue;\n\n    const entryText = await entry.text();\n    const title = match[1];\n    const text = title + '\\n\\n' + entryText;\n\n    notes.push({\n      id: uuid(),\n      text,\n      creation_date: new Date(startMs - i).toISOString(),\n      modification_date: new Date(startMs - i).toISOString(),\n      order: startMs - i * 1000,\n      not_deleted: 1,\n      not_archived: 1,\n      pinned: 0,\n    });\n  }\n\n  return notes;\n}\n\nfunction hasOption(text: string, label: string): boolean {\n  const regexp = new RegExp('^\\\\s*- \\\\[(.)\\\\] ' + label + '$', 'm');\n  const match = text.match(regexp);\n  assert(match, `option \"${label}\" doesn't exist.`);\n  return match[1] === 'x';\n}\n"
  },
  {
    "path": "src/client/LoginPage.css",
    "content": ".login-page {\n  max-width: 300px;\n\n  .form-element {\n    display: flex;\n    flex-direction: column;\n\n    & + .form-element {\n      margin-top: 0.5rem;\n    }\n    label {\n      /* width: 80px; */\n    }\n    input[type='text'] {\n      margin-top: 0.25rem;\n    }\n    input[type='checkbox'] {\n      vertical-align: middle;\n    }\n\n    .strength {\n      font-size: 0.9em;\n      margin-left: 0.5rem;\n    }\n  }\n\n  .buttons {\n    margin-top: 1rem;\n    display: grid;\n    grid-template-columns: 1fr 1fr;\n    grid-gap: 0.5rem;\n\n    & button.login {\n      /* max-width: 150px; */\n      /* width: 100%; */\n      /* margin-left: auto; */\n      /* margin-right: auto; */\n    }\n  }\n\n  .section {\n    margin-top: 2rem;\n    border-top: 1px solid #aadfef;\n    padding-top: 1rem;\n    & p + p {\n      margin: 1rem;\n    }\n  }\n\n  .welcome {\n    text-align: center;\n  }\n\n  .storage-message {\n    text-align: center;\n    button {\n      width: 100%;\n    }\n  }\n}\n"
  },
  {
    "path": "src/client/LoginPage.tsx",
    "content": "import { useRouter } from './router.jsx';\nimport React, { useEffect, useState } from 'react';\nimport * as appStore from './appStore.js';\nimport * as actions from './appStoreActions.jsx';\nimport { PageLayout, PageHeader, PageBody } from './PageLayout.jsx';\nimport _ from 'lodash';\n\ntype LoginPageProps = {};\n\nfunction LoginPage(props: LoginPageProps) {\n  const [username, setUsername] = useState('');\n  const [password, setPassword] = useState('');\n  const valid = Boolean(username && password);\n  let passwordStrength = 0;\n\n  if (password) {\n    if (password.length >= 10) passwordStrength++;\n    if (/[a-z]/.test(password)) passwordStrength++;\n    if (/[A-Z]/.test(password)) passwordStrength++;\n    if (/[0-9]/.test(password)) passwordStrength++;\n    if (/[^a-zA-Z0-9]/.test(password)) passwordStrength++;\n    if (/[^0-9]/.test(password) && password.length >= 16) passwordStrength = 5;\n  }\n\n  async function loginCb() {\n    if (valid) {\n      await actions.login({ username, password }, { importDemoNotes: false });\n    } else {\n      actions.showMessage('Please enter username and password');\n    }\n  }\n  async function signupCb() {\n    if (valid) {\n      if (passwordStrength < 5) {\n        const answer = window.confirm(\n          'A weak password enables a hacker to break the encryption of your data. Are you sure you want to continue?',\n        );\n        if (!answer) return;\n      }\n      await actions.signup({ username, password }, { importDemoNotes: true });\n    } else {\n      actions.showMessage('Please enter username and password');\n    }\n  }\n\n  function keyDownCb(e: React.KeyboardEvent) {\n    if (e.key === 'Enter' && valid) loginCb();\n  }\n\n  const app = appStore.use();\n  const search = useRouter().search;\n\n  useEffect(() => {\n    if (app.user && app.user?.username !== 'demo') {\n      const from = new URLSearchParams(search).get('from');\n      history.replaceState(null, '', from || '/');\n    }\n  }, [app.user]);\n\n  return (\n    <PageLayout>\n      <PageHeader compact />\n      <PageBody>\n        <div className=\"page login-page\">\n          <div className=\"form-element\">\n            <label htmlFor=\"username\">Username</label>\n            <input\n              className=\"text-input small\"\n              type=\"text\"\n              name=\"username\"\n              required\n              minLength={4}\n              maxLength={50}\n              onChange={e => setUsername(e.target.value)}\n              onKeyDown={keyDownCb}\n            />\n          </div>\n          <div className=\"form-element\">\n            <label htmlFor=\"password\">\n              Password\n              {password && (\n                <span className={`strength strength-${passwordStrength}`}> strength: {passwordStrength} / 5</span>\n              )}\n            </label>\n            <input\n              className=\"text-input small\"\n              type=\"password\"\n              name=\"password\"\n              required\n              minLength={8}\n              maxLength={100}\n              onChange={e => setPassword(e.target.value)}\n              onKeyDown={keyDownCb}\n            />\n          </div>\n          {/*app.user?.username === 'demo' && app.notes.length > 0 && (\n            <div className=\"form-element\">\n              <label>\n                <input type=\"checkbox\" onChange={e => setImportDemoNotes(e.target.checked)} checked={importDemoNotes} />{' '}\n                Import {app.notes.length} {app.notes.length === 1 ? 'note' : 'notes'} from demo user (\n                <Link to=\"/\">see notes</Link>)\n              </label>\n            </div>\n          )*/}\n          <div className=\"buttons\">\n            <button className=\"login primary\" onClick={loginCb}>\n              Log in\n            </button>\n            <button className=\"signup\" onClick={signupCb}>\n              Sign up\n            </button>\n          </div>\n          {/*<div className=\"section welcome\">\n            <p>Unforget is a note taking app.</p>\n            <p>Notes will be encrypted on your device(s).</p>\n            <p>Nobody can recover your notes if you lose your password.</p>\n            </div>*/}\n          {/*app.notes.length > 0 && (\n            <div className=\"section storage-message\">\n              <p>\n                There are existing notes on this device.\n                <br />\n                They'll be synced after you log in or sign up.\n              </p>\n              <button onClick={actions.clearStorage}>Clear local storage</button>\n            </div>\n            )*/}\n        </div>\n      </PageBody>\n    </PageLayout>\n  );\n}\n\nexport default LoginPage;\n"
  },
  {
    "path": "src/client/Menu.css",
    "content": ".menu {\n  position: absolute;\n  top: 28px;\n  background: #ffffff;\n  /* backdrop-filter: blur(5px); */\n  z-index: var(--menu-z-index);\n  color: #424242;\n  border-radius: 5px;\n  width: 250px;\n  box-shadow: 5px 5px 14px 7px #00000022;\n  overflow: hidden;\n\n  &.left {\n    left: 0;\n  }\n  &.right {\n    right: 0;\n  }\n  &.center {\n    left: 50%;\n    transform: translate(-50%, 0px);\n  }\n\n  & ul {\n    margin: 0;\n    padding: 0;\n    list-style-type: none;\n\n    & img {\n      width: 15px;\n      height: 15px;\n      margin-left: auto;\n    }\n\n    & li.header {\n      display: flex;\n      align-items: center;\n      font-size: 1.1em;\n      background: var(--menu-header-background);\n      /* background: #e1efff; */\n      padding: 0.5rem 1rem;\n      border-bottom: 1px solid #cacfd5;\n    }\n\n    & li.footer {\n      display: flex;\n      align-items: center;\n      font-size: 0.8em;\n      background: #f0f7ff;\n      padding: 0.25rem 1rem;\n      border-top: 1px solid #cacfd5;\n    }\n\n    & li {\n      &.has-top-separator {\n        border-top: 1px solid #cacfd5;\n      }\n\n      & a {\n        display: flex;\n        align-items: center;\n        white-space: nowrap;\n        padding: 0.5rem 1rem;\n        transition: 0.2s background ease-in-out;\n        /* font-weight: bold; */\n        /* font-size: 1.1em; */\n      }\n      & a:hover {\n        background: #fdddd2;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/client/Menu.tsx",
    "content": "import { useRouter } from './router.jsx';\nimport React, { useState, useEffect, useRef } from 'react';\n\nexport type MenuItem = {\n  isHeader?: boolean;\n  hasTopSeparator?: boolean;\n  label: string;\n  icon: string;\n  to?: string;\n  onClick?: () => any;\n};\n\nexport type MenuProps = { menu: MenuItem[]; side: 'left' | 'right' | 'center'; onClose: () => any; trigger?: string };\n\nexport function Menu(props: MenuProps) {\n  const router = useRouter();\n\n  function menuItemClicked(e: React.MouseEvent<HTMLAnchorElement>) {\n    e.preventDefault();\n    e.stopPropagation();\n    props.onClose();\n    const item = props.menu[Number((e.target as HTMLAnchorElement).dataset.menuIndex)];\n    if (item.onClick) {\n      item.onClick();\n    } else if (item.to && router.pathname !== item.to) {\n      history.pushState(null, '', item.to);\n    }\n  }\n\n  useEffect(() => {\n    function callback(e: MouseEvent) {\n      const target = e.target as HTMLElement | undefined;\n      const clickedOnTrigger = props.trigger && target?.closest(props.trigger);\n      const clickedOnMenu = target?.closest('.menu');\n      if (!clickedOnTrigger && !clickedOnMenu) props.onClose();\n    }\n    window.addEventListener('mousedown', callback);\n    return () => window.removeEventListener('mousedown', callback);\n  }, [props.trigger, props.onClose]);\n\n  return (\n    <div className={`menu ${props.side}`}>\n      <ul>\n        {props.menu.map<React.ReactNode>((item, i) =>\n          item.isHeader ? (\n            <li key={i} className=\"header\">\n              {item.label}\n              <img src={item.icon} />\n            </li>\n          ) : (\n            <li key={i} className={item.hasTopSeparator ? 'has-top-separator' : ''}>\n              <a href={item.to || '#'} onClick={menuItemClicked} className=\"reset\" data-menu-index={i}>\n                {item.label}\n                <img src={item.icon} />\n              </a>\n            </li>\n          ),\n        )}\n      </ul>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/client/NotePage.css",
    "content": ".note-page {\n  margin-bottom: 0;\n\n  .note-container {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    position: relative;\n\n    .editor {\n      flex: 1;\n      padding: 1rem;\n    }\n    .footer {\n      padding: 0.75rem 1rem;\n      font-size: 0.6em;\n      opacity: 0.6;\n      display: flex;\n      justify-content: space-between;\n      white-space: nowrap;\n    }\n  }\n}\n"
  },
  {
    "path": "src/client/NotePage.tsx",
    "content": "import { useRouter, RouteMatch } from './router.jsx';\nimport React, { useCallback, useState, useEffect, useRef } from 'react';\nimport type * as t from '../common/types.js';\nimport { isNoteNewerThan, formatDateTime } from '../common/util.js';\nimport * as storage from './storage.js';\nimport * as appStore from './appStore.js';\nimport * as actions from './appStoreActions.jsx';\nimport { Editor, EditorContext } from './Editor.jsx';\nimport { PageLayout, PageHeader, PageBody, PageAction } from './PageLayout.jsx';\nimport _ from 'lodash';\nimport * as icons from './icons.js';\nimport * as b from './cross-context-broadcast.js';\nimport { addSyncEventListener, removeSyncEventListener, type SyncEvent } from './sync.js';\n// import log from './logger.js';\n\nexport function NotePage() {\n  const app = appStore.use();\n  const { match, loaderData, state: historyState } = useRouter();\n  const [note, setNote] = useState(loaderData!.read() as t.Note | undefined);\n  const editorRef = useRef<EditorContext | null>(null);\n\n  // Here's a dirty hack to fix Safari hiding the fixed toolbar when we focus on the text editor.\n  // Why is this still a thing? why? just why?\n  // Inspired by https://www.codemzy.com/blog/sticky-fixed-header-ios-keyboard-fix\n  // useEffect(() => {\n  //   function setTop() {\n  //     const h = document.getElementById('page-header-inner-wrapper')!;\n  //     // -2 is used to prevent a small gap from appearing especially on IOS Safari.\n  //     let top = Math.max(0, window.scrollY - 2);\n  //     if (window.innerHeight === document.body.offsetHeight) {\n  //       top = 0;\n  //     }\n  //     h.style.paddingTop = `${top}px`;\n\n  //     // Could also fix it by scrolling to top, but then the cursor might go behind the soft keyboard.\n  //     // window.scrollTo(0, 0);\n\n  //     req = requestAnimationFrame(setTop);\n  //   }\n\n  //   let req = requestAnimationFrame(setTop);\n  //   return () => cancelAnimationFrame(req);\n  // }, []);\n\n  // Check for changes in storage initiated externally or internally and possibly replace note.\n  useEffect(() => {\n    async function checkStorageAndUpdateNote() {\n      const newNote = await storage.getNote(match!.params.noteId as string);\n      if (newNote && isNoteNewerThan(newNote, note)) setNote(newNote);\n    }\n\n    function handleBroadcastMessage(message: t.BroadcastChannelMessage) {\n      if (message.type === 'notesInStorageChanged') {\n        checkStorageAndUpdateNote();\n      }\n    }\n\n    function handleSyncEvent(e: SyncEvent) {\n      if (e.type === 'mergedNotes') {\n        checkStorageAndUpdateNote();\n      }\n    }\n\n    b.addListener(handleBroadcastMessage); // External changes.\n    addSyncEventListener(handleSyncEvent); // Internal changes.\n    return () => {\n      removeSyncEventListener(handleSyncEvent);\n      b.removeListener(handleBroadcastMessage);\n    };\n  }, [note, match!.params.noteId]);\n\n  // Keyboard shortcuts.\n  useEffect(() => {\n    function callback(e: KeyboardEvent) {\n      function handle(handler: () => any) {\n        e.preventDefault();\n        e.stopPropagation();\n        handler();\n      }\n      const ctrlOrMeta = e.ctrlKey || e.metaKey;\n\n      if (e.key === 'Enter' && ctrlOrMeta) {\n        handle(goHome);\n      } else if (e.key === 'Escape') {\n        if (ctrlOrMeta) {\n          handle(toggleArchiveCb);\n        } else {\n          handle(goHome);\n        }\n      } else if (e.key === 'Delete' && e.shiftKey && ctrlOrMeta) {\n        handle(deleteCb);\n      } else if (e.key === '.' && ctrlOrMeta) {\n        handle(cycleListStyleCb);\n      } else if (e.key === 'p' && ctrlOrMeta) {\n        handle(togglePinned);\n      }\n    }\n\n    window.addEventListener('keydown', callback);\n    return () => window.removeEventListener('keydown', callback);\n  });\n\n  const goHome = useCallback(() => {\n    if (historyState.index > 0) {\n      history.back();\n    } else {\n      history.pushState(null, '', '/');\n    }\n  }, []);\n\n  const textChangeCb = useCallback(\n    (text: string) => {\n      const newNote: t.Note = { ...note!, text, modification_date: new Date().toISOString() };\n      setNote(newNote);\n      actions.saveNote(newNote);\n    },\n    [note],\n  );\n\n  const toggleArchiveCb = useCallback(() => {\n    const newNote: t.Note = {\n      ...note!,\n      modification_date: new Date().toISOString(),\n      not_archived: note!.not_archived ? 0 : 1,\n    };\n    actions\n      .saveNote(newNote, { message: newNote.not_archived ? 'Unarchived' : 'Archived', immediateSync: true })\n      .then(() => {\n        setNote(newNote);\n        if (!newNote.not_archived) goHome();\n      });\n  }, [goHome, note]);\n\n  const deleteCb = useCallback(() => {\n    if (confirm('Are you sure you want to delete this note?')) {\n      const newNote: t.Note = { ...note!, modification_date: new Date().toISOString(), text: null, not_deleted: 0 };\n      actions.saveNote(newNote, { message: 'Deleted', immediateSync: true }).then(() => {\n        setNote(newNote);\n        goHome();\n      });\n    }\n  }, [goHome, note]);\n\n  const togglePinned = useCallback(() => {\n    const newNote = { ...note!, modification_date: new Date().toISOString(), pinned: note!.pinned ? 0 : 1 };\n    actions\n      .saveNote(newNote, { message: note!.pinned ? 'Unpinned' : 'Pinned', immediateSync: true })\n      .then(() => setNote(newNote));\n  }, [note]);\n\n  // Save note on beforeunload event.\n  useEffect(() => {\n    function callback(e: BeforeUnloadEvent) {\n      if (storage.isSavingNote()) e.preventDefault();\n    }\n    window.addEventListener('beforeunload', callback);\n    return () => window.removeEventListener('beforeunload', callback);\n  }, []);\n\n  // const insertMenu = createInsertMenu(() => editorRef.current!);\n\n  const cycleListStyleCb = useCallback(() => {\n    editorRef.current!.cycleListStyle();\n  }, []);\n\n  const pageActions = note && [\n    <PageAction icon={icons.trashWhite} onClick={deleteCb} title=\"Delete (Ctrl+Shift+Delete or Cmd+Shift+Delete)\" />,\n    <PageAction\n      icon={note.not_archived ? icons.archiveEmptyWhite : icons.archiveFilledWhite}\n      onClick={toggleArchiveCb}\n      title=\"Archive (Ctrl+Esc or Cmd+Esc)\"\n    />,\n    <PageAction\n      icon={note.pinned ? icons.pinFilledWhite : icons.pinEmptyWhite}\n      onClick={togglePinned}\n      title={note.pinned ? 'Unpin (Ctrl+p or Cmd+p)' : 'Pin (Ctrl+p or Cmd+p)'}\n    />,\n    <PageAction icon={icons.cycleListWhite} onClick={cycleListStyleCb} title=\"Cycle list style (Ctrl+. or Cmd+.)\" />,\n    <PageAction icon={icons.checkWhite} onClick={goHome} title=\"Done (Esc or Ctrl+Enter or Cmd+Enter)\" />,\n  ];\n\n  return (\n    <PageLayout>\n      <PageHeader actions={pageActions} />\n      <PageBody>\n        <div className=\"page note-page\">\n          {!note && (app.syncing || app.updatingNotes) && <h2 className=\"page-message\">...</h2>}\n          {!note && !(app.syncing || app.updatingNotes) && <h2 className=\"page-message\">Not found</h2>}\n          {note && (\n            <div className=\"note-container\">\n              <Editor\n                ref={editorRef}\n                className=\"text-input\"\n                placeholder=\"What's on your mind?\"\n                value={note.text ?? ''}\n                onChange={textChangeCb}\n              />\n              <div className=\"footer\">\n                <span>Created on {formatDateTime(new Date(note.creation_date))}</span>\n                {note.creation_date !== note.modification_date && (\n                  <span>Updated on {formatDateTime(new Date(note.modification_date))}</span>\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n      </PageBody>\n    </PageLayout>\n  );\n}\n\nexport async function notePageLoader({ params }: RouteMatch): Promise<t.Note | undefined> {\n  if (appStore.get().user) {\n    return await storage.getNote(params.noteId as string);\n  }\n}\n"
  },
  {
    "path": "src/client/Notes.css",
    "content": ".notes {\n  border: 1px solid var(--box-border-color);\n  border-radius: var(--box-border-radius);\n  overflow: hidden;\n\n  @media (max-width: 800px) {\n    border-radius: 0;\n    border-left: 0;\n    border-right: 0;\n  }\n\n  /* Show selection circles if a selectable note is hovered. */\n  &.selectable .note:hover {\n    @media (pointer: fine) {\n      .select {\n        visibility: visible;\n      }\n      & img.pin {\n        /* visibility: hidden; */\n      }\n    }\n  }\n\n  /* Show selection circles if selection mode is on. */\n  &.has-selection .note {\n    .select {\n      visibility: visible;\n    }\n    & img.pin {\n      /* visibility: hidden; */\n    }\n  }\n\n  /* Resize and move the pin when the selection circle is shown */\n  &.selectable .note:hover img.pin,\n  &.has-selection .note img.pin {\n    top: -1px;\n    right: -2px;\n    width: 15px;\n  }\n\n  .note {\n    padding: 0.2rem 1rem; /* The vertical padding accounts for the line-height of paragraphs and headings */\n    transition: 0.2s background ease-in-out;\n    font-family: inherit;\n    /* word-break: break-word; */\n    /* word-wrap: break-word; */\n    /* line-height: var(--note-line-height); */\n    /* margin: 0; */\n    position: relative;\n    display: flex; /* This is to disable margin collapse */\n    flex-direction: column;\n    min-height: 2rem;\n\n    &.clickable {\n      cursor: pointer;\n      &:hover {\n        background: #d0ebf5;\n      }\n    }\n\n    &.selected {\n      .select {\n        visibility: visible;\n      }\n      /* & img.pin { */\n      /*   visibility: hidden; */\n      /* } */\n    }\n\n    &.pinned {\n      /* border-left: 3px solid var(--box-border-color); */\n      /* &:first-child { */\n      /*   border-radius: var(--box-border-radius) var(--box-border-radius) 0 0; */\n      /* } */\n      /* &:last-child { */\n      /*   border-radius: 0 0 var(--box-border-radius) var(--box-border-radius); */\n      /* } */\n    }\n\n    & + .note {\n      border-top: 1px solid var(--box-border-color);\n    }\n\n    & .select {\n      --select-padding: 6.885px;\n      position: absolute;\n      top: 0;\n      right: 0;\n      /* width: calc(30px + var(--select-padding) * 2); */\n      /* height: calc(30px + var(--select-padding) * 2); */\n      padding: var(--select-padding);\n      visibility: hidden;\n\n      &:hover,\n      &:focus,\n      &:focus-visible,\n      &:active {\n        outline: none;\n        border: none;\n        background: none;\n\n        .circle {\n          box-shadow: 0px 0px 10px 0px #0000002e;\n          transition: 0.1s all ease-in-out;\n        }\n      }\n\n      &.selected .circle {\n        border: 1px solid #888;\n        box-shadow: none;\n      }\n\n      &:not(.selected) .circle img {\n        display: none;\n      }\n\n      .circle {\n        width: 30px;\n        height: 30px;\n        border-radius: 50%;\n        background: #fff;\n        border: 1px solid #ccc;\n        position: relative;\n        /* box-shadow: 0 0 3px 0px #ccc inset; */\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        & img {\n          margin-top: 3px;\n        }\n      }\n    }\n\n    & img.pin {\n      position: absolute;\n      top: 2px;\n      right: 2px;\n      width: 20px;\n      opacity: 0.5;\n      transition: all 0.2s ease-in-out;\n    }\n\n    & .empty {\n      opacity: 0.5;\n      font-style: italic;\n    }\n\n    & hr {\n      margin: 1rem 0;\n      border-color: var(--box-border-color);\n      border-style: dashed;\n      opacity: 0.5;\n    }\n\n    & table {\n      border: 1px dashed var(--box-border-color);\n      border-collapse: collapse;\n    }\n\n    & th,\n    td {\n      padding: 0.25rem 0.5rem;\n      border: 1px dashed var(--box-border-color);\n    }\n\n    & pre {\n      background: #00000005;\n      padding: 0.5rem;\n      white-space: pre-wrap;\n    }\n    & blockquote {\n      background: #00000005;\n      padding: 0 0.5rem;\n    }\n\n    & table {\n      background: #00000005;\n    }\n\n    & h1,\n    h2,\n    h3,\n    h4,\n    h5,\n    h6 {\n      &:not(:first-child) {\n        margin-top: 1rem;\n      }\n    }\n\n    & h1 {\n      font-size: 1.1rem;\n    }\n\n    & h2 {\n      font-size: 1rem;\n    }\n\n    & h3,\n    h4,\n    h5,\n    h6 {\n      font-size: 1rem;\n    }\n\n    & ul {\n      padding-inline-start: calc(var(--checkbox-size) + var(--single-space-size) + var(--checkbox-right-margin));\n\n      & > li.task-list-item {\n        list-style-type: none;\n        /* &::marker { */\n        /*   content: ''; */\n        /* } */\n\n        & > input[type='checkbox'],\n        & > p > input[type='checkbox'] {\n          margin-left: calc(-1 * (var(--checkbox-size) + var(--single-space-size) + var(--checkbox-right-margin)));\n        }\n      }\n\n      & > li:not(.task-list-item) {\n        position: relative;\n        list-style-type: none;\n        /* &::marker { */\n        /*   content: ''; */\n        /* } */\n\n        &:empty {\n          height: 1.5rem;\n        }\n\n        &:before {\n          content: '';\n          /* float: left; */\n          /* display: list-item; */\n          /* list-style-type: circle; */\n          /* list-style-position: inside; */\n          /* width: 20px; */\n          /* font-size: 1.5rem; */\n          /* line-height: 0.8; */\n          /* vertical-align: middle; */\n          /* width: 1px; */\n          left: calc(\n            -1 * (var(--checkbox-size) / 2 + var(--single-space-size) + var(--checkbox-right-margin) +\n                  var(--bulletpoint-size) / 2 + 1px)\n          );\n          width: var(--bulletpoint-size);\n          height: var(--bulletpoint-size);\n          border-radius: 50%;\n          border: 2px solid #444;\n          position: absolute;\n          top: 6px;\n        }\n      }\n    }\n\n    & ol {\n      padding-inline-start: calc(var(--checkbox-size) + var(--single-space-size));\n\n      & > li.task-list-item {\n        padding-left: calc(var(--checkbox-size) + var(--single-space-size) + var(--checkbox-right-margin) + 6px);\n\n        & > input[type='checkbox'],\n        & > p > input[type='checkbox'] {\n          margin-left: calc(\n            var(--single-space-size) + 2px - var(--checkbox-size) - var(--single-space-size) -\n              var(--checkbox-right-margin) - 6px\n          );\n        }\n      }\n    }\n\n    /* & ol { */\n    /*   padding-inline-start: calc(var(--checkbox-size) + var(--checkbox-first-margin)); */\n    /* } */\n\n    /*\n    & ul {\n      padding-inline-start: var(--checkbox-size-with-margin);\n    }\n\n    & ol {\n      padding-inline-start: calc(var(--checkbox-size) + var(--checkbox-first-margin));\n    }\n\n    & ul > li.task-list-item {\n      text-indent: calc(-1 * var(--checkbox-size-with-margin));\n\n      &::marker {\n        content: '';\n      }\n    }\n\n    & ol > li.task-list-item > input[type='checkbox'] {\n      margin-left: var(--checkbox-second-margin);\n    }\n    */\n\n    & input[type='checkbox'] {\n      border: 1px solid #aadfef;\n      border-radius: 5px;\n      outline: none;\n      padding: 0.5rem 0rem;\n      width: var(--checkbox-size);\n      height: var(--checkbox-size);\n      vertical-align: middle;\n      margin: 0;\n      margin-top: -2px;\n      accent-color: var(--checkbox-accent);\n      margin-right: var(--checkbox-right-margin);\n    }\n  }\n}\n"
  },
  {
    "path": "src/client/Notes.tsx",
    "content": "import React, { memo, useState } from 'react';\nimport type * as t from '../common/types.js';\nimport { assert } from '../common/util.js';\nimport * as md from '../common/mdFns.js';\n// import * as actions from './appStoreActions.jsx';\nimport { useClickWithoutDrag } from './hooks.jsx';\nimport _ from 'lodash';\nimport * as icons from './icons.js';\nimport { toHtml } from 'hast-util-to-html';\nimport { fromMarkdown } from 'mdast-util-from-markdown';\nimport { toHast } from 'mdast-util-to-hast';\nimport { gfm } from 'micromark-extension-gfm';\nimport { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm';\nimport { visit } from 'unist-util-visit';\nimport { visitParents } from 'unist-util-visit-parents';\nimport { newlineToBreak } from 'mdast-util-newline-to-break';\n\nexport function Notes(props: {\n  notes: t.Note[];\n  readonly?: boolean;\n  onHashLinkClick?: (hash: string) => any;\n  onNoteChange?: (note: t.Note) => any;\n  onNoteClick?: (note: t.Note) => any;\n  onToggleNoteSelection?: (note: t.Note) => any;\n  hiddenNoteId?: string;\n  hideContentAfterBreak?: boolean;\n  noteSelection?: string[];\n  selectable?: boolean;\n}) {\n  const notes = props.notes.filter(n => n.id !== props.hiddenNoteId);\n  return (\n    <div className={`notes ${props.noteSelection ? 'has-selection' : ''} ${props.selectable ? 'selectable' : ''}`}>\n      {notes.map(note => (\n        <Note\n          key={note.id}\n          note={note}\n          readonly={props.readonly}\n          onHashLinkClick={props.onHashLinkClick}\n          onNoteChange={props.onNoteChange}\n          onNoteClick={props.onNoteClick}\n          onToggleNoteSelection={props.onToggleNoteSelection}\n          hideContentAfterBreak={props.hideContentAfterBreak}\n          selected={props.noteSelection?.includes(note.id)}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport const Note = memo(function Note(props: {\n  note: t.Note;\n  readonly?: boolean;\n  onHashLinkClick?: (hash: string) => any;\n  onNoteChange?: (note: t.Note) => any;\n  onNoteClick?: (note: t.Note) => any;\n  onToggleNoteSelection?: (note: t.Note) => any;\n  hideContentAfterBreak?: boolean;\n  selected?: boolean;\n}) {\n  // Do not modify the text here because we want the position of each element in mdast and hast to match\n  // exactly the original text.\n  const text = props.note.text;\n\n  const [expanded, setExpanded] = useState(false);\n\n  function toggleExpanded(e: React.MouseEvent) {\n    e.preventDefault();\n    e.stopPropagation();\n    setExpanded(!expanded);\n    if (expanded) {\n      setTimeout(() => {\n        const elem = document.getElementById(props.note.id);\n        if (elem) window.scrollTo({ top: elem.getBoundingClientRect().top + window.scrollY - 50, behavior: 'smooth' });\n      }, 0);\n    }\n  }\n\n  function clickCb(e: React.MouseEvent) {\n    // history.pushState(null, '', `/n/${props.note.id}`);\n    const elem = e.target as HTMLElement;\n    const link = elem.closest('a');\n    const input = elem.closest('input');\n    const li = elem.closest('li');\n    if (input && li && !props.readonly) {\n      e.preventDefault();\n      e.stopPropagation();\n\n      const [start, end] = [Number(li.dataset.posStart), Number(li.dataset.posEnd)];\n      if (!Number.isFinite(start) || !Number.isFinite(end)) {\n        console.error(`Got unknown start or end position for li: ${start}, ${end}`);\n        return;\n      }\n\n      // console.log('checkbox at li:', start, end);\n      // console.log('text:', `<START>${text!.substring(start, end)}<END>`);\n\n      const liText = text!.substring(start, end);\n      const ulCheckboxRegExp = /^(\\s*[\\*+-]\\s*\\[)([xX ])(\\].*)$/m;\n      const olCheckboxRegExp = /^(\\s*\\d+[\\.\\)]\\s*\\[)([xX ])(\\].*)$/m;\n      const match = liText.match(ulCheckboxRegExp) ?? liText.match(olCheckboxRegExp);\n      if (!match) {\n        console.error(`LiText did not match checkbox regexp: `, liText);\n        return;\n      }\n      const newLi = match[1] + (match[2] === ' ' ? 'x' : ' ') + match[3];\n\n      const newText = md.insertText(text!, newLi, { start, end: start + match[0].length });\n      const newNote: t.Note = { ...props.note, text: newText, modification_date: new Date().toISOString() };\n      props.onNoteChange?.(newNote);\n    } else if (link) {\n      const baseURL = new URL(document.baseURI);\n      const targetURL = new URL(link.href, document.baseURI);\n      const isRelative = baseURL.origin === targetURL.origin;\n\n      if (isRelative) {\n        e.preventDefault();\n        e.stopPropagation();\n        if (baseURL.pathname === targetURL.pathname && baseURL.hash !== targetURL.hash) {\n          props.onHashLinkClick?.(targetURL.hash);\n        } else {\n          history.pushState(null, '', link.href);\n        }\n      } else {\n        e.stopPropagation();\n      }\n    } else {\n      props.onNoteClick?.(props.note);\n    }\n  }\n\n  const { onClick, onMouseDown } = useClickWithoutDrag(clickCb);\n\n  function selectCb(e: React.MouseEvent) {\n    e.preventDefault();\n    e.stopPropagation();\n    props.onToggleNoteSelection?.(props.note);\n  }\n\n  // function inputClickCb(e: React.MouseEvent) {\n  //   e.preventDefault();\n  //   e.stopPropagation();\n  // }\n\n  const mdast = fromMarkdown(text ?? '', {\n    extensions: [gfm()],\n    mdastExtensions: [gfmFromMarkdown()],\n  });\n  newlineToBreak(mdast);\n  // console.log('mdast', mdast);\n  assert(mdast.type === 'root', 'hast does not have root');\n  const noteIsEmpty = mdast.children.length === 0;\n\n  // Turn the first line into a heading if it's not already a heading and it is followed by two new lines\n  {\n    const first = mdast.children[0];\n    if (first?.type === 'paragraph' && text?.match(/^[^\\r\\n]+\\r?\\n\\r?\\n/g)) {\n      mdast.children[0] = { type: 'heading', depth: 1, position: first.position, children: first.children };\n    }\n  }\n\n  // Remove everything after thematicBreak\n  const breakMdNodeIndex = mdast.children.findIndex(node => node.type === 'thematicBreak');\n  if (props.hideContentAfterBreak && !expanded && breakMdNodeIndex !== -1) {\n    mdast.children.splice(breakMdNodeIndex);\n  }\n\n  const hast = toHast(mdast);\n  // console.log(hast);\n\n  const baseURL = new URL(document.baseURI);\n  visit(hast, 'element', function (node) {\n    // Enable input nodes.\n    if (node.tagName === 'input') {\n      node.properties['disabled'] = Boolean(props.readonly);\n    }\n\n    // Set external links' target to '_blank'.\n    if (node.tagName === 'a' && typeof node.properties['href'] === 'string') {\n      const targetURL = new URL(node.properties['href'], document.baseURI);\n      if (baseURL.origin !== targetURL.origin) {\n        node.properties['target'] = '_blank';\n      }\n    }\n\n    // Set start and end position of all elements.\n    node.properties['data-pos-start'] = node.position?.start.offset;\n    node.properties['data-pos-end'] = node?.position?.end.offset;\n  });\n\n  const html = toHtml(hast);\n\n  return (\n    <div\n      id={props.note.id}\n      className={`note ${props.onNoteClick ? 'clickable' : ''} ${props.selected ? 'selected' : ''} ${\n        props.note.pinned ? 'pinned' : ''\n      }`}\n      onMouseDown={onMouseDown}\n      onClick={onClick}\n    >\n      {Boolean(props.note.pinned) && <img className=\"pin\" src={icons.pinFilled} />}\n      {noteIsEmpty ? (\n        <div>\n          <h2 className=\"empty\">Empty note</h2>\n        </div>\n      ) : (\n        <div dangerouslySetInnerHTML={{ __html: html }} />\n      )}\n      {props.hideContentAfterBreak && breakMdNodeIndex >= 0 && (\n        <p>\n          <a href=\"#toggle-expand\" onClick={toggleExpanded}>\n            {expanded ? 'show less' : 'show more'}\n          </a>\n        </p>\n      )}\n      <div className={`select ${props.selected ? 'selected' : ''}`} tabIndex={0} onClick={selectCb}>\n        <div className=\"circle\">\n          <img src={icons.check} />\n        </div>\n      </div>\n    </div>\n  );\n});\n"
  },
  {
    "path": "src/client/NotesPage.css",
    "content": ".notes-page {\n  .new-note-container {\n    display: flex;\n    flex-direction: column;\n    transition: 0.15s transform ease-out;\n\n    &.below-second-row {\n      transform: translate3d(\n        0,\n        calc((var(--margin-after-page-header) * 2 - var(--page-header-second-row-height)) / 2),\n        0\n      );\n    }\n\n    &.sticky {\n      position: sticky;\n      top: var(--page-header-height);\n      z-index: var(--sticky-z-index);\n      overflow: visible;\n\n      &.below-second-row {\n        transform: none;\n        top: calc(var(--page-header-height) + var(--page-header-second-row-height));\n        transition: none;\n      }\n\n      .editor {\n        /* more padding to clear the top where messages are shown. */\n        /* min-height: 100px; */\n        box-shadow: 5px 5px 14px 7px #00000022;\n        border-top-left-radius: 0;\n        border-top-right-radius: 0;\n        max-height: 80vh;\n\n        /* NOTE Chaning the size of the editor causes jumps on scroll position restoration during page transitions */\n        /* padding-top: 1.2rem; */\n        /* padding-bottom: 1.2rem; */\n      }\n\n      /* IOS Safari doesn't show the shadow if it's on .editor */\n      @media (max-width: 800px) {\n        box-shadow: 5px 5px 14px 7px #00000022;\n        .editor {\n          box-shadow: none;\n        }\n      }\n    }\n\n    &.invisible {\n      opacity: 0;\n      pointer-events: none;\n    }\n\n    &:not(.sticky) .editor:focus {\n      outline: 2px solid #aadfef;\n    }\n\n    .editor {\n      padding-top: 1.5rem;\n      padding-bottom: 1.5rem;\n    }\n  }\n\n  .notes {\n    margin-top: var(--margin-after-page-header);\n  }\n\n  & button.load-more {\n    margin-top: 2rem;\n    padding-left: 2rem;\n    padding-right: 2rem;\n  }\n}\n"
  },
  {
    "path": "src/client/NotesPage.tsx",
    "content": "import { RouteMatch } from './router.jsx';\nimport React, { useState, useEffect, useRef, memo } from 'react';\nimport { useStoreAndRestoreScrollY } from './hooks.js';\nimport type * as t from '../common/types.js';\nimport * as cutil from '../common/util.js';\nimport * as storage from './storage.js';\nimport * as appStore from './appStore.js';\nimport * as actions from './appStoreActions.jsx';\nimport log from './logger.js';\nimport { Editor, EditorContext } from './Editor.jsx';\nimport { PageLayout, PageHeader, PageBody, PageAction, type PageHeaderSecondRowProps } from './PageLayout.jsx';\nimport _ from 'lodash';\nimport * as icons from './icons.js';\nimport { Notes } from './Notes.jsx';\nimport * as b from './cross-context-broadcast.js';\nimport { addSyncEventListener, removeSyncEventListener, type SyncEvent } from './sync.js';\n// import log from './logger.js';\n\ntype NotesPageProps = {};\n\nexport function NotesPage(_props: NotesPageProps) {\n  const app = appStore.use();\n  const [newNote, setNewNote] = useState<t.Note>();\n  // const [newNoteText, setNewNoteText] = useState('');\n  // const [newNotePinned, setNewNotePinned] = useState(false);\n  const [editorOpen, setEditorOpen] = useState(false);\n  const [editorFocused, setEditorFocused] = useState(false);\n  const [stickyEditor, setStickyEditor] = useState(false);\n  const editorRef = useRef<EditorContext | null>(null);\n  useStoreAndRestoreScrollY();\n\n  // Check for changes in storage initiated externally or internally and update the notes.\n  useEffect(() => {\n    function handleBroadcastMessage(message: t.BroadcastChannelMessage) {\n      if (message.type === 'notesInStorageChanged') {\n        actions.updateNotes();\n      }\n    }\n\n    function handleSyncEvent(e: SyncEvent) {\n      if (e.type === 'mergedNotes') {\n        actions.updateNotes();\n      }\n    }\n\n    b.addListener(handleBroadcastMessage); // External changes.\n    addSyncEventListener(handleSyncEvent); // Internal changes.\n    return () => {\n      removeSyncEventListener(handleSyncEvent);\n      b.removeListener(handleBroadcastMessage);\n    };\n  }, []);\n\n  // Keyboard shortcuts.\n  useEffect(() => {\n    function callback(e: KeyboardEvent) {\n      function handle(handler: () => any) {\n        e.preventDefault();\n        e.stopPropagation();\n        handler();\n      }\n      const ctrlOrMeta = e.ctrlKey || e.metaKey;\n\n      if (e.key === 'Enter' && ctrlOrMeta) {\n        handle(confirmNewNoteCb);\n      } else if (e.key === 'Escape' && !ctrlOrMeta) {\n        if (editorOpen) {\n          handle(confirmNewNoteCb);\n        } else if (app.search !== undefined) {\n          handle(toggleNoteSearchCb);\n        } else if (app.noteSelection) {\n          handle(toggleNoteSelectionMode);\n        }\n      } else if (e.key === 'Delete' && e.shiftKey && ctrlOrMeta) {\n        handle(cancelNewNoteCb);\n      } else if (e.key === '.' && ctrlOrMeta) {\n        handle(cycleListStyleCb);\n      } else if (e.key === 'p' && ctrlOrMeta) {\n        handle(togglePinned);\n      } else if (e.key === 'ArrowUp' && e.shiftKey && ctrlOrMeta) {\n        handle(actions.moveNoteSelectionToTop);\n      } else if (e.key === 'ArrowDown' && e.shiftKey && ctrlOrMeta) {\n        handle(actions.moveNoteSelectionToBottom);\n      } else if (e.key === 'ArrowUp' && ctrlOrMeta) {\n        handle(actions.moveNoteSelectionUp);\n      } else if (e.key === 'ArrowDown' && ctrlOrMeta) {\n        handle(actions.moveNoteSelectionDown);\n      }\n\n      // Ignore the following shortcuts if input or textarea is focused\n      // or if ctrl or meta key is pressed\n      if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName ?? '')) return;\n      if (ctrlOrMeta) return;\n\n      if (e.key === '/') {\n        if (app.search !== undefined) {\n          handle(() => document.getElementById('search-input')?.focus());\n        } else {\n          handle(toggleNoteSearchCb);\n        }\n      } else if (e.key === 'p') {\n        handle(toggleHidePinnedNotes);\n      } else if (e.key === 'n') {\n        if (editorOpen) {\n          handle(() => editorRef.current?.textareaRef.current?.focus());\n        } else {\n          handle(startNewNoteCb);\n        }\n      } else if (e.key === 's') {\n        handle(toggleNoteSelectionMode);\n      } else if (e.key === 'A' && app.showArchive) {\n        handle(unarchiveNoteSelection);\n      } else if (e.key === 'a' && !app.showArchive) {\n        handle(archiveNoteSelection);\n      }\n    }\n\n    window.addEventListener('keydown', callback);\n    return () => window.removeEventListener('keydown', callback);\n  });\n\n  function archiveNoteSelection() {\n    const count = app.noteSelection?.length ?? 0;\n    if (count > 0 && confirm(`Are you sure you want to archive ${count} note(s)?`)) {\n      actions.archiveNoteSelection();\n    }\n  }\n\n  function unarchiveNoteSelection() {\n    const count = app.noteSelection?.length ?? 0;\n    if (count > 0 && confirm(`Are you sure you want to unarchive ${count} note(s)?`)) {\n      actions.unarchiveNoteSelection();\n    }\n  }\n\n  function saveNewNote(changes: { text?: string | null; pinned?: number; not_deleted?: number }) {\n    let savedNote = {\n      ...(newNote ?? cutil.createNewNote('')),\n      ...changes,\n      modification_date: new Date().toISOString(),\n    };\n    setNewNote(savedNote);\n    actions.saveNote(savedNote);\n  }\n\n  function deleteNewNote() {\n    if (newNote) {\n      appStore.update(app => {\n        app.notes = app.notes.filter(n => n.id !== newNote.id);\n      });\n      saveNewNote({ text: null, not_deleted: 0 });\n    }\n  }\n\n  function confirmNewNoteCb() {\n    if (!newNote?.text?.trim()) {\n      cancelNewNoteCb();\n      return;\n    }\n    actions.showMessage('Note added', { type: 'info' });\n    editorRef.current!.focus();\n    setNewNote(undefined);\n    actions.updateNotes();\n  }\n\n  async function cancelNewNoteCb() {\n    if (!newNote || confirm('Are you sure you want to delete this note?')) {\n      deleteNewNote();\n      setNewNote(undefined);\n      setEditorOpen(false);\n      actions.updateNotes();\n      (document.activeElement as HTMLElement | undefined)?.blur();\n    }\n  }\n\n  // async function askUserToCancelNewNoteCb() {\n  //   if (!newNote?.text?.trim() || confirm('Are you sure you want to delete the new note?')) {\n  //     cancelNewNoteCb();\n  //   }\n  // }\n\n  function newNoteTextChanged(text: string) {\n    saveNewNote({ text });\n  }\n\n  // Set editor's stickiness on mount and on scroll.\n  useEffect(() => {\n    function scrolled() {\n      setStickyEditor(window.scrollY > 64);\n      reduceNotePagesDebounced();\n    }\n\n    scrolled();\n    window.addEventListener('scroll', scrolled);\n    return () => window.removeEventListener('scroll', scrolled);\n  }, []);\n\n  function editorFocusCb() {\n    setEditorOpen(true);\n    setEditorFocused(true);\n  }\n\n  function editorBlurCb() {\n    setEditorFocused(false);\n  }\n\n  // Cancel new note if editor is empty and has lost focus.\n  useEffect(() => {\n    let timeout: any;\n    if (editorOpen && !editorFocused && !newNote?.text) {\n      timeout = setTimeout(() => cancelNewNoteCb(), 300);\n    }\n    return () => clearTimeout(timeout);\n  }, [editorOpen, newNote, editorFocused, cancelNewNoteCb]);\n\n  function togglePinned() {\n    editorRef.current!.focus();\n    saveNewNote({ pinned: newNote?.pinned ? 0 : 1 });\n  }\n\n  function toggleHidePinnedNotes() {\n    const value = !app.hidePinnedNotes;\n    storage.setSetting(value, 'hidePinnedNotes');\n    appStore.update(app => {\n      app.hidePinnedNotes = value;\n    });\n    actions.updateNotes();\n    actions.showMessage(value ? 'Hiding pinned notes' : 'Showing pinned notes');\n  }\n\n  function loadMore() {\n    appStore.update(app => {\n      app.notePages++;\n    });\n    actions.updateNotes();\n  }\n\n  function toggleNoteSearchCb() {\n    appStore.update(app => {\n      if (app.search === undefined) {\n        app.search = '';\n        app.noteSelection ??= [];\n      } else {\n        app.search = undefined;\n        if (!app.noteSelection?.length) app.noteSelection = undefined;\n      }\n    });\n    actions.updateNotes();\n  }\n\n  function searchChangeCb(e: React.ChangeEvent<HTMLInputElement>) {\n    appStore.update(app => {\n      app.search = e.target.value;\n    });\n    actions.updateNotesDebounced();\n  }\n\n  // function searchKeyDownCb(e: React.KeyboardEvent) {\n  //   if (e.key === 'Escape') {\n  //     e.preventDefault();\n  //     e.stopPropagation();\n  //     toggleNoteSearchCb();\n  //   }\n  // }\n\n  function cycleListStyleCb() {\n    editorRef.current!.cycleListStyle();\n  }\n\n  function startNewNoteCb() {\n    setEditorOpen(true);\n    editorRef.current!.focus();\n  }\n\n  function toggleNoteSelectionMode() {\n    appStore.update(app => {\n      app.noteSelection = app.noteSelection ? undefined : [];\n    });\n  }\n\n  const pageActions: React.ReactNode[] = [];\n  if (editorOpen) {\n    pageActions.push(\n      <PageAction\n        icon={icons.trashWhite}\n        onClick={cancelNewNoteCb}\n        title=\"Delete (Ctrl+Shift+Delete or Cmd+Shift+Delete)\"\n      />,\n      <PageAction\n        icon={newNote?.pinned ? icons.pinFilledWhite : icons.pinEmptyWhite}\n        onClick={togglePinned}\n        title={newNote?.pinned ? 'Unpin (Ctrl+p or Cmd+p)' : 'Pin (Ctrl+p or Cmd+p)'}\n      />,\n      <PageAction icon={icons.cycleListWhite} onClick={cycleListStyleCb} title=\"Cycle list style (Ctrl+. or Cmd+.)\" />,\n      <PageAction icon={icons.checkWhite} onClick={confirmNewNoteCb} title=\"Done (Esc or Ctrl+Enter or Cmd+Enter)\" />,\n    );\n  } else if (app.search === undefined) {\n    if (app.noteSelection) {\n      pageActions.push(\n        <PageAction\n          icon={icons.circleDeselectWhite}\n          onClick={toggleNoteSelectionMode}\n          title={'Clear selection (s or Esc)'}\n        />,\n      );\n    } else {\n      pageActions.push(\n        <PageAction icon={icons.circleSelectWhite} onClick={toggleNoteSelectionMode} title={'Select (s)'} />,\n      );\n    }\n    pageActions.push(\n      <PageAction icon={icons.searchWhite} onClick={toggleNoteSearchCb} title=\"Search (/)\" />,\n      <PageAction\n        icon={app.hidePinnedNotes ? icons.hidePinnedWhite2 : icons.showPinnedWhite}\n        onClick={toggleHidePinnedNotes}\n        title={app.hidePinnedNotes ? 'Show pinned notes (p)' : 'Hide pinned notes (p)'}\n      />,\n      <PageAction icon={icons.addWhite} onClick={startNewNoteCb} title=\"New note (n)\" />,\n    );\n  } else {\n    pageActions.push(\n      <input\n        id=\"search-input\"\n        placeholder={app.showArchive ? 'Search archive ...' : 'Search ...'}\n        className=\"search action\"\n        value={app.search}\n        onChange={searchChangeCb}\n        // onKeyDown={searchKeyDownCb}\n        autoFocus\n      />,\n      <PageAction\n        icon={app.hidePinnedNotes ? icons.hidePinnedWhite2 : icons.showPinnedWhite}\n        onClick={toggleHidePinnedNotes}\n        title={app.hidePinnedNotes ? 'Show pinned notes (p)' : 'Hide pinned notes (p)'}\n      />,\n      <PageAction\n        className=\"close-search\"\n        icon={icons.xWhite}\n        onClick={toggleNoteSearchCb}\n        title=\"Close search (Esc)\"\n      />,\n    );\n  }\n\n  let secondRow: PageHeaderSecondRowProps | undefined;\n  if (app.noteSelection) {\n    // const allPinned = app.notes.every(note => note.pinned);\n    const allArchived = app.notes.every(note => !note.not_archived);\n\n    secondRow = {\n      title:\n        app.noteSelection.length === 0\n          ? 'Select notes'\n          : app.noteSelection.length === 1\n            ? '1 selected'\n            : `${app.noteSelection.length} selected`,\n      actions: [\n        allArchived ? (\n          <PageAction\n            icon={icons.archiveFilledWhite}\n            onClick={unarchiveNoteSelection}\n            title=\"Unarchive selection (Shift+a)\"\n          />\n        ) : (\n          <PageAction icon={icons.archiveEmptyWhite} onClick={archiveNoteSelection} title=\"Archive selection (a)\" />\n        ),\n        <PageAction\n          icon={icons.chevronDownDoubleWhite}\n          onClick={actions.moveNoteSelectionToBottom}\n          title=\"Move selection to the bottom (Ctrl+Shift+Down or Cmd+Shift+Down)\"\n        />,\n        <PageAction\n          icon={icons.chevronUpDoubleWhite}\n          onClick={actions.moveNoteSelectionToTop}\n          title=\"Move selection to the top (Ctrl+Shift+Up or Cmd+Shift+Up)\"\n        />,\n        <PageAction\n          icon={icons.chevronDownWhite}\n          onClick={actions.moveNoteSelectionDown}\n          title=\"Move selection down (Ctrl+Down or Cmd+Down)\"\n        />,\n        <PageAction\n          icon={icons.chevronUpWhite}\n          onClick={actions.moveNoteSelectionUp}\n          title=\"Move selection up (Ctrl+Up or Cmd+Up)\"\n        />,\n      ],\n    };\n  }\n\n  return (\n    <PageLayout>\n      <PageHeader\n        actions={pageActions}\n        title={app.showArchive ? '/ archive' : undefined}\n        hasSearch={app.search !== undefined}\n        secondRow={secondRow}\n      />\n      <PageBody>\n        <div className=\"page notes-page\">\n          <div\n            className={`new-note-container ${stickyEditor ? 'sticky' : ''} ${\n              stickyEditor && !editorOpen ? 'invisible' : ''\n            } ${secondRow ? 'below-second-row' : ''}`}\n          >\n            <Editor\n              ref={editorRef}\n              className=\"text-input\"\n              placeholder=\"What's on your mind?\"\n              value={newNote?.text ?? ''}\n              onChange={newNoteTextChanged}\n              autoExpand\n              onFocus={editorFocusCb}\n              onBlur={editorBlurCb}\n            />\n          </div>\n          {app.notes.length > 0 && <NotesFromApp hiddenNoteId={newNote?.id} />}\n          {!app.notes.length && (app.syncing || app.updatingNotes) && <h2 className=\"page-message\">...</h2>}\n          {/*!app.notes.length && !(app.syncing || app.updatingNotes) && <h2 className=\"page-message\">No notes found</h2>*/}\n          {!app.allNotePagesLoaded && (\n            <button className=\"load-more primary button-row\" onClick={loadMore}>\n              Load more\n            </button>\n          )}\n        </div>\n      </PageBody>\n    </PageLayout>\n  );\n}\n\nconst NotesFromApp = memo(function NotesFromApp(props: { hiddenNoteId?: string }) {\n  const app = appStore.use();\n  return (\n    <Notes\n      notes={app.notes}\n      hiddenNoteId={props.hiddenNoteId}\n      onNoteChange={actions.saveNoteAndQuickUpdateNotes}\n      onNoteClick={goToNote}\n      onToggleNoteSelection={actions.toggleNoteSelection}\n      noteSelection={app.noteSelection}\n      hideContentAfterBreak\n      selectable\n    />\n  );\n});\n\nfunction goToNote(note: t.Note) {\n  history.pushState(null, '', `/n/${note.id}`);\n}\n\nexport async function notesPageLoader(match: RouteMatch) {\n  // Update app.showArchive and noteSelection when transitioning between / and /archive.\n  appStore.update(app => {\n    const showArchive = match.pathname === '/archive';\n    if (showArchive !== app.showArchive) {\n      app.showArchive = showArchive;\n      app.noteSelection = undefined;\n      // app.notesUpdateRequestTimestamp = Date.now();\n    }\n  });\n\n  if (appStore.get().user) {\n    log('notesPageLoader calling updateNotes');\n    // Not awaiting this causes glitches especially when going from / to /archive and back with scroll restoration.\n    await actions.updateNotes();\n  }\n}\n\nfunction reduceNotePagesImmediately() {\n  const notes = document.querySelectorAll('.note');\n  for (const [i, note] of notes.entries()) {\n    const rect = note.getBoundingClientRect();\n    if (rect.top > window.innerHeight * 2 + window.scrollY) {\n      actions.reduceNotePages(i);\n      break;\n    }\n  }\n}\n\nconst reduceNotePagesDebounced = _.debounce(reduceNotePagesImmediately, 1000);\n"
  },
  {
    "path": "src/client/Notifications.css",
    "content": ".msg-bar {\n  position: fixed;\n  bottom: 3rem;\n  /* left: 0; */\n  /* right: 0; */\n  /* width: 100%; */\n  /* border-top: 1px solid white; */\n  color: white;\n  font-size: 0.8em;\n  margin-left: auto;\n  margin-right: auto;\n  max-width: var(--page-max-width);\n  width: 100%;\n  display: flex;\n  justify-content: center;\n  pointer-events: none;\n\n  /* &.has-sticky { */\n  /*   /\\* justify-content: flex-end; *\\/ */\n  /*   font-size: 0.7em; */\n\n  /*   &.error .msg-bar-inner-container { */\n  /*     background: none; */\n  /*     color: #b90000; */\n  /*   } */\n  /*   &.info .msg-bar-inner-container { */\n  /*     background: none; */\n  /*     color: #609dbb; */\n  /*   } */\n\n  /*   .msg-bar-inner-container { */\n  /*     padding: 0; */\n  /*     box-shadow: none; */\n  /*     width: unset; */\n  /*     text-decoration: underline; */\n  /*     text-underline-offset: 2px; */\n  /*   } */\n  /* } */\n\n  &.error .msg-bar-inner-container {\n    background: #b90000ee;\n  }\n  &.info .msg-bar-inner-container {\n    background: #609dbbee;\n  }\n\n  .msg-bar-inner-container {\n    /* width: 100%; */\n    padding: 0 2rem;\n    border-radius: 3px;\n    box-shadow: 0px 4px 14px 1px #00000022;\n\n    & p {\n      text-align: center;\n      margin: 0;\n      padding: 0.25rem 1rem;\n    }\n  }\n}\n\n.update-app-container {\n  position: fixed;\n  bottom: 3rem;\n  left: 0;\n  right: 0;\n  display: flex;\n  justify-content: center;\n\n  & button {\n    box-shadow: 0px 4px 14px 7px #00000022;\n    width: 200px;\n  }\n}\n"
  },
  {
    "path": "src/client/Notifications.tsx",
    "content": "import * as actions from './appStoreActions.jsx';\nimport * as appStore from './appStore.js';\nimport React from 'react';\n\nexport default function Notifications() {\n  const app = appStore.use();\n\n  return (\n    <>\n      {app.message && (\n        <div className={`msg-bar ${app.message.type} `}>\n          <div className=\"msg-bar-inner-container\">\n            <p>{app.message.text.substring(0, 100)}</p>\n          </div>\n        </div>\n      )}\n      {app.requirePageRefresh && (\n        <div className=\"update-app-container\">\n          <button className=\"primary\" onClick={actions.updateApp}>\n            Click to update app\n          </button>\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/client/PageLayout.css",
    "content": "#page-header {\n  z-index: var(--page-header-z-index);\n  background: var(--page-header-background);\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n\n  &.compact #page-header-inner-wrapper {\n    height: var(--page-header-height);\n    background: var(--page-header-background);\n    box-shadow: 0px 4px 14px 1px #00000022;\n\n    & h1 {\n      font-size: 1rem;\n      letter-spacing: 0px;\n      font-family: monospace;\n      color: white;\n    }\n  }\n\n  &.has-search #page-header-inner-wrapper .first-row-content .title {\n    @media (max-width: 500px) {\n      display: none;\n    }\n  }\n\n  #page-header-inner-wrapper {\n    position: absolute;\n    /* The value -1 is used to prevent a small gap from appearing especially on IOS Safari. */\n    /* padding-top is then set in js. See NotePage.tsx. */\n    /* top: -1px; */\n    left: 0;\n    right: 0;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n\n    transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);\n\n    .first-row-content {\n      height: var(--page-header-height);\n      margin: 0 auto;\n      max-width: var(--page-max-width);\n      width: 100%;\n      position: relative;\n      display: flex;\n      align-items: center;\n      background: var(--page-header-background);\n      box-shadow: 0px 4px 14px 1px #00000022;\n\n      .menu-button-container {\n        display: flex;\n        align-items: center;\n        margin-left: 1rem;\n\n        .menu-button {\n          position: relative;\n          width: 25px;\n          height: 25px;\n\n          > a {\n            display: flex;\n          }\n        }\n      }\n\n      .title {\n        position: relative;\n        margin-left: 0.5rem;\n        display: flex;\n        align-items: center;\n        white-space: nowrap;\n\n        & h1 {\n          margin: 0;\n        }\n        & h2 {\n          margin: 0 0.5rem 0 0.5rem;\n        }\n\n        & h1,\n        & h2 {\n          font-size: 0.8rem;\n          letter-spacing: 0px;\n          font-family: monospace;\n          color: white;\n        }\n\n        /* position: absolute; */\n        /* bottom: 0.5rem; */\n        /* left: calc(100% + 0.25rem); */\n        /* text-align: left; */\n\n        .queue-count {\n          margin-left: 0.5rem;\n          font-size: 0.7em;\n          color: white;\n        }\n        /* .online-indicator { */\n        /*   margin-left: 0.25rem; */\n        /*   width: 7px; */\n        /*   height: 7px; */\n        /*   border-radius: 50%; */\n        /*   background: #51bc51; */\n        /* } */\n      }\n\n      .actions {\n        margin-right: 1rem;\n      }\n    }\n\n    .second-row-content {\n      height: var(--page-header-second-row-height);\n      margin: 0 auto;\n      max-width: var(--page-max-width);\n      width: 100%;\n      position: relative;\n      display: flex;\n      align-items: center;\n      border-top: 0.5px solid #fff;\n      background: var(--page-header-second-row-background);\n      box-shadow: 0px 4px 14px 1px #00000022;\n\n      & h1.heading {\n        margin: 0;\n        margin-left: 1rem;\n        white-space: nowrap;\n        font-size: 0.8rem;\n        letter-spacing: 0px;\n        font-family: monospace;\n        color: white;\n      }\n\n      .actions {\n        margin-right: 1rem;\n\n        /* .action img { */\n        /*   width: 20px; */\n        /*   height: 20px; */\n        /* } */\n      }\n    }\n\n    .actions {\n      flex: 1 0 0;\n      height: 100%;\n      margin-left: 1rem;\n      display: flex;\n      align-items: center;\n      justify-content: flex-end;\n\n      .action + .action {\n        margin-left: 1.5rem;\n\n        @media (max-width: 800px) {\n          margin-left: 1rem;\n        }\n      }\n\n      & input.search + .action.close-search {\n        margin-left: 0.5rem;\n      }\n\n      & input.search {\n        border-radius: 0;\n        border: 0;\n        border-bottom: 0;\n        /* flex: 1; */\n        width: 100%;\n        max-width: 300px;\n        /* width: 500px; */\n        /* margin: 0 auto; */\n        /* border: 1px solid #aadfef; */\n        /* border-radius: 5px; */\n        outline: none;\n        padding: 0.25rem 0.5rem;\n      }\n\n      .action {\n        position: relative;\n        display: flex;\n        align-items: center;\n\n        & > a {\n          color: white;\n          height: 100%;\n          display: flex;\n          align-items: center;\n\n          @media (max-width: 800px) {\n            font-size: 0.9em;\n          }\n\n          &.bold {\n            font-weight: bold;\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/client/PageLayout.tsx",
    "content": "import { useRouter } from './router.jsx';\nimport React, { useCallback, useState } from 'react';\nimport { useCallbackCancelEvent } from './hooks.js';\nimport * as actions from './appStoreActions.jsx';\nimport { Menu, MenuItem } from './Menu.jsx';\nimport * as appStore from './appStore.js';\nimport _ from 'lodash';\nimport * as icons from './icons.js';\nimport { sync, requireQueueSync } from './sync.js';\n\nexport function PageLayout(props: { children: React.ReactNode }) {\n  return <>{props.children}</>;\n}\n\nexport type PageHeaderSecondRowProps = {\n  title: string;\n  actions: React.ReactNode;\n};\n\ntype PageHeaderProps = {\n  menu?: MenuItem[];\n  actions?: React.ReactNode;\n  title?: string;\n  hasSearch?: boolean;\n  compact?: boolean;\n  secondRow?: PageHeaderSecondRowProps;\n};\n\nexport function PageHeader(props: PageHeaderProps) {\n  return (\n    <div id=\"page-header\" className={`${props.hasSearch ? 'has-search' : ''} ${props.compact ? 'compact' : ''}`}>\n      <div id=\"page-header-inner-wrapper\">\n        {props.compact ? <PageHeaderContentCompact /> : <PageHeaderFirstRowContent {...props} />}\n        {props.secondRow && <PageHeaderSecondRowContent {...props.secondRow} />}\n      </div>\n    </div>\n  );\n}\n\nfunction PageHeaderContentCompact() {\n  return <h1 className=\"heading\">Unforget</h1>;\n}\n\nfunction PageHeaderFirstRowContent(props: PageHeaderProps) {\n  const app = appStore.use();\n  if (!app.user) throw new Error('PageHeaderFirstRowContent requires user');\n  const [menuOpen, setMenuOpen] = useState(false);\n\n  const toggleMenu = useCallbackCancelEvent(() => setMenuOpen(x => !x), []);\n\n  const fullSync = useCallback(() => {\n    requireQueueSync();\n    sync();\n    actions.showMessage('Syncing ...');\n  }, []);\n\n  const forceCheckAppUpdate = useCallback(() => {\n    actions.forceCheckAppUpdate();\n    actions.showMessage('Checking for updates ...');\n  }, []);\n\n  const router = useRouter();\n\n  function goToNotes(e?: React.UIEvent) {\n    e?.preventDefault();\n    e?.stopPropagation();\n    setMenuOpen(false);\n    if (router.pathname === '/') {\n      window.scrollTo(0, 0);\n    } else {\n      history.pushState(null, '', '/');\n    }\n  }\n\n  let menu: MenuItem[] | undefined;\n  menu = _.compact([\n    { label: _.upperFirst(app.user.username), icon: icons.user, isHeader: true },\n    app.user.username === 'demo' && { label: 'Log in / Sign up', icon: icons.logIn, to: '/login' },\n    ...(props.menu || []),\n    { label: 'Notes', icon: icons.notes, onClick: goToNotes, to: '/' },\n    { label: 'Archive', icon: icons.archiveEmpty, to: '/archive' },\n    { label: 'Import', icon: icons.import, to: '/import' },\n    { label: 'Export', icon: icons.export, to: '/export' },\n    { label: 'About', icon: icons.info, to: '/about' },\n    { label: 'Full sync', icon: icons.refreshCcw, onClick: fullSync, hasTopSeparator: true },\n    { label: 'Check app updates', icon: icons.upgrade, onClick: forceCheckAppUpdate },\n    { label: 'Log out', icon: icons.logOut, onClick: actions.logout, hasTopSeparator: true },\n  ]);\n\n  return (\n    <div className=\"first-row-content\">\n      {!_.isEmpty(menu) && (\n        <div className=\"menu-button-container\">\n          <div className=\"menu-button\">\n            <a href=\"#\" onClick={toggleMenu} className=\"reset\" id=\"page-header-menu-trigger\">\n              <img src={icons.menuWhite} />\n            </a>\n            {menuOpen && <Menu menu={menu!} side=\"left\" onClose={toggleMenu} trigger=\"#page-header-menu-trigger\" />}\n          </div>\n        </div>\n      )}\n      <div className=\"title\">\n        {/*\n          <div className=\"logo\">\n            <Link to=\"/\">\n              <img src=\"/barefront.svg\" />\n            </Link>\n            </div>\n            */}\n        <h1 className=\"heading\">\n          <a href=\"/\" className=\"reset\" onClick={goToNotes}>\n            Unforget\n          </a>\n        </h1>\n        {props.title && <h2>{props.title}</h2>}\n        {app.user?.username !== 'demo' && app.queueCount > 0 && <div className=\"queue-count\">({app.queueCount})</div>}\n        {/*app.online && <div className=\"online-indicator\" />*/}\n      </div>\n      <div className=\"actions\">{props.actions}</div>\n    </div>\n  );\n}\n\nfunction PageHeaderSecondRowContent(props: PageHeaderSecondRowProps) {\n  return (\n    <div className=\"second-row-content\">\n      <h1 className=\"heading\">{props.title}</h1>\n      <div className=\"actions\">{props.actions}</div>\n    </div>\n  );\n}\n\nexport function PageBody(props: { children: React.ReactNode }) {\n  return props.children;\n}\n\nexport function PageAction(props: {\n  className?: string;\n  label?: string;\n  icon?: string;\n  onClick?: () => any;\n  bold?: boolean;\n  menu?: MenuItem[];\n  title: string;\n}) {\n  const [menuOpen, setMenuOpen] = useState(false);\n  const toggleMenu = useCallbackCancelEvent(() => setMenuOpen(x => !x), []);\n  const clicked = useCallbackCancelEvent(() => {\n    if (props.menu) toggleMenu();\n    props.onClick?.();\n  }, [props.menu, props.onClick]);\n\n  // We need action-container because <a> cannot be nested inside another <a> which we need for the menu.\n  return (\n    <div\n      className={`action ${props.className || ''}`}\n      key={`${props.label || '_'} ${props.icon || '_'}`}\n      title={props.title}\n    >\n      <a href=\"#\" onClick={clicked} className={`page-action-menu-trigger reset ${props.bold ? 'bold' : ''}`}>\n        {props.label}\n        {props.icon && <img src={props.icon} />}\n      </a>\n      {props.menu && menuOpen && (\n        <Menu menu={props.menu} side=\"center\" onClose={toggleMenu} trigger=\".page-action-menu-trigger\" />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/client/api.ts",
    "content": "import { ServerError, CACHE_VERSION } from '../common/util.js';\n\nexport async function post<T>(pathname: string, body?: any, params?: Record<string, string>): Promise<T> {\n  const paramsStr = new URLSearchParams(params).toString();\n  const res = await fetch(`${pathname}?${paramsStr}`, {\n    // const res = await fetch(pathname, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'X-Client-Cache-Version': String(CACHE_VERSION) },\n    body: body && JSON.stringify(body),\n  });\n  if (!res.ok) {\n    const error = await createServerError(res);\n    // if (error.type === 'app_requires_update') {\n    //   postMessageToServiceWorker({ command: 'update' });\n    // }\n    throw error;\n  }\n  return await res.json();\n}\n\nexport async function createServerError(res: Response): Promise<ServerError> {\n  const contentType = getResponseContentType(res);\n  if (contentType === 'application/json') {\n    return ServerError.fromJSON(await res.json());\n  } else {\n    console.error(await res.text());\n    return new ServerError(`unknown response of type ${contentType}`, res.status);\n  }\n}\n\nfunction getResponseContentType(res: Response): string | undefined {\n  return res.headers.get('Content-Type')?.split(/\\s*;\\s*/g)[0];\n}\n"
  },
  {
    "path": "src/client/appStore.tsx",
    "content": "import { produce } from 'immer';\nimport { useSyncExternalStore } from 'react';\nimport type * as t from '../common/types.js';\n\nlet store: t.AppStore;\nlet listeners: t.AppStoreListener[] = [];\n\nexport function get(): t.AppStore {\n  return store;\n}\n\nexport function set(newStore: t.AppStore) {\n  const oldStore = store;\n  store = newStore;\n  for (const listener of listeners) listener(store, oldStore);\n}\n\nexport function update(recipe: t.AppStoreRecipe) {\n  set(produce(store, recipe));\n}\n\nexport function addListener(listener: t.AppStoreListener) {\n  listeners.push(listener);\n  return () => removeListener(listener);\n}\n\nexport function removeListener(listener: t.AppStoreListener) {\n  const index = listeners.indexOf(listener);\n  if (index !== -1) listeners.splice(index, 1);\n}\n\nexport function use(): t.AppStore {\n  return useSyncExternalStore(addListener, get);\n}\n\ndeclare global {\n  var dev: any;\n}\n\nglobalThis.dev ??= {};\nglobalThis.dev.getStore = get;\n"
  },
  {
    "path": "src/client/appStoreActions.tsx",
    "content": "import type * as t from '../common/types.js';\nimport * as storage from './storage.js';\nimport * as appStore from './appStore.js';\nimport * as actions from './appStoreActions.js';\nimport log from './logger.js';\nimport { generateEncryptionSalt, calcClientPasswordHash, makeEncryptionKey } from './crypto.js';\nimport { getUserTokenFromCookie, setUserCookies } from './cookies.js';\nimport { postToServiceWorker } from './clientToServiceWorkerApi.js';\nimport * as api from './api.js';\nimport { bytesToHexString, createNewNote } from '../common/util.jsx';\nimport _ from 'lodash';\nimport welcome1 from './notes/welcome1.md';\nimport { sync, syncDebounced } from './sync.js';\nimport * as b from './cross-context-broadcast.js';\n\nexport async function initAppStore() {\n  // let showArchive = false;\n  // let hidePinnedNotes = false;\n  // let user: t.ClientLocalUser | undefined;\n\n  // if (readFromStorage) {\n  const [showArchive, hidePinnedNotes, user] = await Promise.all([\n    storage.getSetting('showArchive').then(Boolean),\n    storage.getSetting('hidePinnedNotes').then(Boolean),\n    storage.getUser(),\n  ]);\n  // }\n\n  appStore.set({\n    showArchive,\n    hidePinnedNotes,\n    notes: [],\n    // notesUpdateRequestTimestamp: 0,\n    // notesUpdateTimestamp: -1,\n    notePages: 1,\n    notePageSize: 50,\n    allNotePagesLoaded: false,\n    online: navigator.onLine,\n    queueCount: 0,\n    syncing: false,\n    updatingNotes: false,\n    user,\n    requirePageRefresh: false,\n  });\n\n  await updateNotes();\n}\n\nexport async function setUpDemo() {\n  const encryption_salt = bytesToHexString(generateEncryptionSalt());\n  await loggedIn({ username: 'demo', password: 'demo' }, { username: 'demo', token: 'demo', encryption_salt });\n\n  const notes: t.Note[] = [createNewNote(welcome1)];\n  for (const note of notes) await saveNote(note);\n  await updateNotes();\n}\n\nexport async function updateNotes() {\n  try {\n    const start = Date.now();\n    log('updateNotes started');\n    const { notePages, notePageSize, hidePinnedNotes, search, showArchive } = appStore.get();\n    // const notesUpdateTimestamp = Date.now();\n    appStore.update(app => {\n      app.updatingNotes = true;\n    });\n    const { done, notes } = await storage.getNotes({\n      limit: notePageSize * notePages,\n      hidePinnedNotes,\n      search,\n      archive: showArchive,\n    });\n    appStore.update(app => {\n      app.notes = notes;\n      app.allNotePagesLoaded = done;\n      // app.notesUpdateTimestamp = notesUpdateTimestamp;\n    });\n    log(`updateNotes done in ${Date.now() - start}ms`);\n  } catch (error) {\n    gotError(error as Error);\n  } finally {\n    appStore.update(app => {\n      app.updatingNotes = false;\n    });\n  }\n}\n\nexport function reduceNotePages(lastItemIndex: number) {\n  log(`trying to reduce note pages lastItemIndex: ${lastItemIndex}`);\n  const { notePages, notePageSize } = appStore.get();\n  const newNotePages = Math.floor((lastItemIndex + 1 + (notePageSize - 1)) / notePageSize);\n  if (newNotePages < notePages) {\n    log(`reducing note pages from ${notePages} to ${newNotePages}`);\n    appStore.update(app => {\n      app.notes = app.notes.slice(0, newNotePages * notePageSize);\n      app.notePages = newNotePages;\n    });\n  }\n}\n\n// export async function updateNotesIfDirty() {\n//   const { notesUpdateTimestamp, notesUpdateRequestTimestamp } = appStore.get();\n//   if (notesUpdateTimestamp < notesUpdateRequestTimestamp) {\n//     await updateNotes();\n//   }\n// }\n\nexport const updateNotesDebounced = _.debounce(updateNotes, 300, { leading: false, trailing: true, maxWait: 1000 });\n\nexport async function updateQueueCount() {\n  try {\n    const queueCount = await storage.countQueuedNotes();\n    appStore.update(app => {\n      app.queueCount = queueCount;\n    });\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport async function login(credentials: t.UsernamePassword, opts?: { importDemoNotes?: boolean }) {\n  try {\n    const loginData: t.LoginData = {\n      username: credentials.username,\n      password_client_hash: await calcClientPasswordHash(credentials),\n    };\n    const loginResponse: t.LoginResponse = await api.post('/api/login', loginData);\n    await loggedIn(credentials, loginResponse, opts);\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport async function signup(credentials: t.UsernamePassword, opts?: { importDemoNotes?: boolean }) {\n  try {\n    const signupData: t.SignupData = {\n      username: credentials.username,\n      password_client_hash: await calcClientPasswordHash(credentials),\n      encryption_salt: bytesToHexString(generateEncryptionSalt()),\n    };\n    const loginResponse: t.LoginResponse = await api.post('/api/signup', signupData);\n\n    // We want the client to pick the encryption salt to make sure it really is random and secure.\n    if (loginResponse.encryption_salt !== signupData.encryption_salt) {\n      await resetUser();\n      throw new Error('Server might be compromised. The encryption parameters were tampered with.');\n    }\n\n    await loggedIn(credentials, loginResponse, opts);\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport async function logout() {\n  try {\n    const { user } = appStore.get();\n    if (!user) return;\n\n    // Calling a history API here may not work due to the timing.\n    // It may still redirect to /login witht a from=XXX search param.\n    // window.history.replaceState(null, '', '/');\n\n    await resetUser();\n    await storage.clearAll();\n    await initAppStore();\n\n    // Send token as param instead of relying on cookies because by the time the request is sent,\n    // the cookie has already been cleared.\n    api.post('/api/logout', null, { token: user.token }).catch(log.error);\n\n    // Broadcast to all contexts that we just logged out.\n    b.broadcast({ type: 'refreshPage' });\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport async function clearStorage() {\n  try {\n    await storage.clearAll();\n    await initAppStore();\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport function gotError(error: Error) {\n  log.error(error);\n  showMessage(error.message, { type: 'error' });\n}\n\nexport function showMessage(text: string, opts?: { type?: 'info' | 'error' }) {\n  const timestamp = Date.now();\n  appStore.update(app => {\n    app.message = { text, type: opts?.type || 'info', timestamp };\n  });\n  setTimeout(() => {\n    if (appStore.get().message?.timestamp === timestamp) {\n      appStore.update(app => {\n        app.message = undefined;\n      });\n    }\n  }, 5000);\n}\n\nexport async function saveNote(note: t.Note, opts?: { message?: string; immediateSync?: boolean }) {\n  await saveNotes([note], opts);\n}\n\nexport async function saveNotes(notes: t.Note[], opts?: { message?: string; immediateSync?: boolean }) {\n  try {\n    await storage.saveNotes(notes);\n    if (opts?.message) {\n      showMessage(opts.message, { type: 'info' });\n    }\n    // appStore.update(app => {\n    //   app.notesUpdateRequestTimestamp = Date.now();\n    // });\n    if (opts?.immediateSync) {\n      sync();\n    } else {\n      syncDebounced();\n    }\n    b.broadcast({ type: 'notesInStorageChanged' });\n    // postToServiceWorker({ command: 'tellOthersNotesInStorageChanged' });\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\n// export async function clearNotes() {\n//   await storage.clearNotes();\n//   appStore.update(app => {\n//     app.notes = [];\n//     app.notesUpdateRequestTimestamp = Date.now();\n//   });\n// }\n\nexport async function saveNoteAndQuickUpdateNotes(note: t.Note) {\n  try {\n    await storage.saveNote(note);\n    appStore.update(app => {\n      const i = app.notes.findIndex(x => x.id === note.id);\n      if (i !== -1) app.notes[i] = note;\n    });\n    sync();\n    b.broadcast({ type: 'notesInStorageChanged' });\n    // postToServiceWorker({ command: 'tellOthersNotesInStorageChanged' });\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport function toggleNoteSelection(note: t.Note) {\n  appStore.update(app => {\n    if (!app.noteSelection) app.noteSelection = [];\n\n    const i = app.noteSelection.indexOf(note.id);\n    if (i !== -1) {\n      app.noteSelection.splice(i, 1);\n    } else {\n      app.noteSelection.push(note.id);\n    }\n\n    if (app.noteSelection.length === 0) {\n      app.noteSelection = undefined;\n    }\n  });\n}\n\nexport async function archiveNoteSelection() {\n  const app = appStore.get();\n  if (app.noteSelection?.length) {\n    const notes = _.compact(await storage.getNotesById(app.noteSelection));\n    const newNotes = notes.map(note => ({\n      ...note,\n      modification_date: new Date().toISOString(),\n      not_archived: 0,\n    }));\n    await saveNotes(newNotes, { message: `Archived ${newNotes.length} note(s)`, immediateSync: true });\n    appStore.update(app => {\n      app.noteSelection = undefined;\n    });\n    await updateNotes();\n  }\n}\n\nexport async function unarchiveNoteSelection() {\n  const app = appStore.get();\n  if (app.noteSelection?.length) {\n    const notes = _.compact(await storage.getNotesById(app.noteSelection));\n    const newNotes = notes.map(note => ({\n      ...note,\n      modification_date: new Date().toISOString(),\n      not_archived: 1,\n    }));\n    await saveNotes(newNotes, { message: `Unarchived ${newNotes.length} note(s)`, immediateSync: true });\n    appStore.update(app => {\n      app.noteSelection = undefined;\n    });\n    await updateNotes();\n  }\n}\n\nexport async function moveNoteSelectionUp() {\n  await moveNoteSelection(storage.moveNotesUp);\n}\n\nexport async function moveNoteSelectionDown() {\n  await moveNoteSelection(storage.moveNotesDown);\n}\n\nexport async function moveNoteSelectionToTop() {\n  await moveNoteSelection(storage.moveNotesToTop);\n}\n\nexport async function moveNoteSelectionToBottom() {\n  await moveNoteSelection(storage.moveNotesToBottom);\n}\n\nasync function moveNoteSelection(moveFn: (ids: string[]) => Promise<void>) {\n  const app = appStore.get();\n  if (app.noteSelection) {\n    await moveFn(app.noteSelection);\n    syncDebounced();\n    b.broadcast({ type: 'notesInStorageChanged' });\n    await updateNotes();\n  }\n}\n\nasync function makeClientLocalUserFromServer(\n  credentials: t.UsernamePassword,\n  loginResponse: t.LoginResponse,\n): Promise<t.ClientLocalUser> {\n  return {\n    username: loginResponse.username,\n    token: loginResponse.token,\n    encryptionKey: await makeEncryptionKey(credentials.password, loginResponse.encryption_salt),\n  };\n}\n\n/**\n * Mostly, useful during development if we manually delete one but not the other.\n * Just in case make sure that the token and the user in appStore are in sync.\n */\nexport async function makeSureConsistentUserAndCookie() {\n  const tokenFromCookie = getUserTokenFromCookie();\n  const { user } = appStore.get();\n  const consistent = Boolean(user && tokenFromCookie && user.token === tokenFromCookie);\n  if (!consistent) await actions.resetUser();\n}\n\nexport async function resetUser() {\n  setUserCookies('');\n  await storage.clearUser();\n  appStore.update(app => {\n    app.user = undefined;\n  });\n}\n\nasync function loggedIn(\n  credentials: t.UsernamePassword,\n  loginResponse: t.LoginResponse,\n  opts?: { importDemoNotes?: boolean },\n) {\n  const user = await makeClientLocalUserFromServer(credentials, loginResponse);\n  if (!opts?.importDemoNotes) {\n    await clearStorage();\n  }\n  setUserCookies(loginResponse.token); // Needed for the demo user.\n  await storage.setUser(user);\n  appStore.update(app => {\n    app.user = user;\n  });\n  sync();\n  b.broadcast({ type: 'refreshPage' });\n  // postToServiceWorker({ command: 'tellOthersToRefreshPage' });\n}\n\nexport async function checkAppUpdate() {\n  try {\n    if (!appStore.get().online) return;\n\n    const updateInterval = process.env.NODE_ENV === 'development' ? 10 * 1000 : 24 * 3600 * 1000;\n    const lastCheck = await storage.getSetting<string>('lastAppUpdateCheck');\n    if (!lastCheck || new Date(lastCheck).valueOf() < Date.now() - updateInterval) {\n      await checkAppUpdateHelper();\n    }\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport async function forceCheckAppUpdate() {\n  try {\n    await checkAppUpdateHelper();\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nasync function checkAppUpdateHelper() {\n  const registration = await navigator.serviceWorker.ready;\n  await registration.update();\n  await storage.setSetting(new Date().toISOString(), 'lastAppUpdateCheck');\n}\n\nexport async function requireAppUpdate() {\n  appStore.update(app => {\n    app.message = undefined;\n    app.requirePageRefresh = true;\n  });\n}\n\nexport async function updateApp() {\n  try {\n    await storage.setSetting(true, 'updatingApp');\n    window.location.reload();\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n\nexport async function notifyIfAppUpdated() {\n  try {\n    const updatingApp = await storage.getSetting('updatingApp');\n    if (updatingApp) {\n      showMessage('App updated (details in the about page)');\n      await storage.setSetting(false, 'updatingApp');\n    }\n  } catch (error) {\n    gotError(error as Error);\n  }\n}\n"
  },
  {
    "path": "src/client/clientToServiceWorkerApi.ts",
    "content": "import type { ClientToServiceWorkerMessage } from '../common/types.js';\nimport log from './logger.js';\n\nexport async function postToServiceWorker(message: ClientToServiceWorkerMessage) {\n  try {\n    const reg = await navigator.serviceWorker?.ready;\n    reg?.active?.postMessage(message);\n  } catch (error) {\n    log.error(error);\n  }\n}\n"
  },
  {
    "path": "src/client/common.css",
    "content": ":root {\n  --app-background: #ddf7ff;\n  --page-header-height: 2rem;\n  --page-header-second-row-height: 2rem;\n  --page-header-background: #448199;\n  --page-header-second-row-background: #366f85;\n  --menu-header-background: #d6f5ff;\n  --box-border-color: #94becd;\n  --input-border-color: #aadfef;\n  --box-border-radius: 5px;\n  --page-header-z-index: 200;\n  --margin-after-page-header: 2rem;\n  --page-content-margin-top: calc(var(--page-header-height) + var(--margin-after-page-header));\n  --page-content-margin-bottom: 2rem;\n  --page-max-width: 800px;\n  --menu-z-index: 100;\n  --sticky-z-index: 50;\n  --line-height: 1.4;\n  --checkbox-size: 25px;\n  --single-space-size: 5px;\n  --checkbox-right-margin: 7px;\n  --bulletpoint-size: 6px;\n  /* --checkbox-size-with-margin: calc( */\n  /*   var(--checkbox-size) + var(--checkbox-first-margin) + var(--checkbox-second-margin) */\n  /* ); */\n  --checkbox-accent: #197af8;\n}\n\nhtml,\nbody {\n  margin: 0;\n  padding: 0;\n  height: 100%;\n  font-size: 16px;\n}\n\nhtml {\n  line-height: var(--line-height);\n  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji,\n    Segoe UI Emoji, Segoe UI Symbol;\n}\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  line-height: var(--line-height);\n}\n\nbody {\n  background: var(--app-background);\n  display: flex;\n  flex-direction: column;\n}\n\n#app {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\np,\nul,\nol,\npre,\nblockquote,\ntable {\n  margin: 0.5rem 0;\n}\n\nblockquote {\n  margin-left: 2rem;\n}\n\nul ol,\nol ul,\nul ul,\nol ol {\n  margin: 0.25rem 0;\n}\nli + li {\n  margin-top: 0.25rem;\n}\n\nbutton {\n  background: var(--app-background);\n  border: 1px solid #448199;\n  padding: 0.5rem;\n  border-radius: 5px;\n  transition: 0.2s background ease-in-out;\n  color: #000;\n\n  &:hover {\n    background: #cfecf5;\n  }\n\n  &:focus-visible {\n    outline: 1px solid #448199;\n  }\n\n  &.primary {\n    background: #448199;\n    color: #fff;\n\n    &:hover {\n      background: #3e778e;\n    }\n\n    /* &:focus-visible { */\n    /* outline: 1px solid #448199; */\n    /* } */\n  }\n\n  &.button-row {\n    margin-left: auto;\n    margin-right: auto;\n    padding-left: 1rem;\n    padding-right: 1rem;\n    /* width: 400px; */\n\n    @media (max-width: 800px) {\n      border-radius: 0;\n      width: 100%;\n    }\n  }\n}\n\n/* p { */\n/* margin: 0; */\n/* } */\n\n/* p + p { */\n/*   margin-top: 1rem; */\n/* } */\n\n.text-input {\n  /* width: 500px; */\n  /* margin: 0 auto; */\n  border: 1px solid var(--input-border-color);\n  border-radius: 5px;\n  outline: none;\n  padding: 0.5rem 0.75rem;\n\n  &:not(.small) {\n    @media (max-width: 800px) {\n      border-radius: 0;\n      border-left: 0;\n      border-right: 0;\n    }\n  }\n}\n\n/* .file { */\n/*   position: relative; */\n/*   display: inline-block; */\n/*   cursor: pointer; */\n/*   height: 2.5rem; */\n/*   text-align: left; */\n\n/*   & input { */\n/*     min-width: 14rem; */\n/*     margin: 0; */\n/*     opacity: 0; */\n/*   } */\n/*   & .file-custom { */\n/*     position: absolute; */\n/*     top: 0; */\n/*     right: 0; */\n/*     left: 0; */\n/*     z-index: 5; */\n/*     height: 1.5rem; */\n/*     padding: 0.5rem 1rem; */\n/*     line-height: 1.5; */\n/*     color: #555; */\n/*     background-color: #fff; */\n/*     border: 1px solid var(--input-border-color); */\n/*     /\\* border: 0.075rem solid #ddd; *\\/ */\n/*     /\\* border-radius: 0.25rem; *\\/ */\n/*     outline: none; */\n/*     border-radius: 5px; */\n/*     /\\* box-shadow: inset 0 0.2rem 0.4rem rgba(0, 0, 0, 0.05); *\\/ */\n/*     user-select: none; */\n/*     overflow: hidden; */\n\n/*     &::before { */\n/*       position: absolute; */\n/*       top: -0.075rem; */\n/*       right: -0.075rem; */\n/*       bottom: -0.075rem; */\n/*       z-index: 6; */\n/*       display: block; */\n/*       content: 'Browse'; */\n/*       height: 1.5rem; */\n/*       padding: 0.5rem 1rem; */\n/*       line-height: 1.5; */\n/*       color: #333; */\n/*       background-color: white; */\n/*       border: 0.075rem solid #ddd; */\n/*       border-radius: 0 0.25rem 0.25rem 0; */\n/*     } */\n\n/*     .custom-label { */\n/*       white-space: nowrap; */\n/*     } */\n/*   } */\n/* } */\n\n.page {\n  flex: 1;\n  margin: var(--page-content-margin-top) auto var(--page-content-margin-bottom) auto;\n  max-width: var(--page-max-width);\n  width: 100%;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n}\n\nh2.page-message {\n  margin-top: 3rem;\n  text-align: center;\n  color: #666;\n}\n\na.reset {\n  &,\n  &:visited,\n  &:focus,\n  &:active {\n    color: inherit;\n    text-decoration: none;\n  }\n}\n"
  },
  {
    "path": "src/client/cookies.ts",
    "content": "import log from './logger.js';\n\nexport function getCookie(name: string): string | undefined {\n  return document.cookie\n    .split('; ')\n    .find(row => row.startsWith(name + '='))\n    ?.split('=')[1];\n}\n\nexport function getUserTokenFromCookie(): string | undefined {\n  return getCookie('unforget_token');\n}\n\nexport function setUserCookies(token: string) {\n  const maxAge = 10 * 365 * 24 * 3600; // 10 years in seconds\n  document.cookie = `unforget_token=${token}; max-age=${maxAge}; path=/`;\n}\n"
  },
  {
    "path": "src/client/cross-context-broadcast.ts",
    "content": "import type { BroadcastChannelMessage } from '../common/types.js';\nimport log from './logger.js';\n\nexport type Listener = (msg: BroadcastChannelMessage) => any;\n\nlet channel: BroadcastChannel;\nlet listeners: Listener[] = [];\n\nexport function init() {\n  channel = new BroadcastChannel('unforget');\n\n  channel.onmessage = event => {\n    const message: BroadcastChannelMessage = event.data;\n    log('broadcast received:', message);\n\n    for (const listener of listeners) {\n      try {\n        listener(message);\n      } catch (error) {\n        log.error(error);\n      }\n    }\n  };\n}\n\nexport function addListener(listener: Listener) {\n  listeners.push(listener);\n}\n\nexport function removeListener(listener: Listener) {\n  const i = listeners.indexOf(listener);\n  if (i !== -1) listeners.splice(i, 1);\n}\n\n/**\n * Broadcasts will not be received in the same context. They are only recieved by other contexts (other tabs/windows)\n */\nexport function broadcast(message: Omit<BroadcastChannelMessage, 'unforgetContextId'>) {\n  const fullMessage: BroadcastChannelMessage = { unforgetContextId: window.unforgetContextId, ...message };\n  channel.postMessage(fullMessage);\n}\n"
  },
  {
    "path": "src/client/crypto.ts",
    "content": "import type * as t from '../common/types.js';\nimport { bytesToHexString, hexStringToBytes } from '../common/util.js';\nimport log from './logger.js';\n\n/**\n * Derive from username, password, and a static random number\n */\nexport async function calcClientPasswordHash({ username, password }: t.UsernamePassword): Promise<string> {\n  const text = username + password + '32261572990560219427182644435912532';\n  const encoder = new TextEncoder();\n  const textBuf = encoder.encode(text);\n  const hashBuf = await crypto.subtle.digest('SHA-256', textBuf);\n  return bytesToHexString(new Uint8Array(hashBuf));\n}\n\nexport function generateEncryptionSalt(): Uint8Array<ArrayBuffer> {\n  return crypto.getRandomValues(new Uint8Array(16));\n}\n\nexport function generateIV(): Uint8Array<ArrayBuffer> {\n  return crypto.getRandomValues(new Uint8Array(12));\n}\n\nexport async function bytesToBase64(buffer: ArrayBuffer): Promise<string> {\n  // Note: using FileReader.readAsDataURL() is about 30x slower.\n  let binaryString = '';\n  const bytes = new Uint8Array(buffer);\n  const len = bytes.byteLength;\n  for (let i = 0; i < len; i++) {\n    binaryString += String.fromCharCode(bytes[i]);\n  }\n  const res = btoa(binaryString);\n  return res;\n}\n\nexport async function base64ToBytes(base64: string): Promise<ArrayBuffer> {\n  // Note: using await (await fetch(dataUrl)).arrayBuffer() is about 50x slower\n  const binaryString = atob(base64);\n  const len = binaryString.length;\n  const bytes = new Uint8Array(len);\n  for (let i = 0; i < len; i++) {\n    bytes[i] = binaryString.charCodeAt(i);\n  }\n  return bytes.buffer;\n}\n\nexport async function makeEncryptionKey(password: string, salt: string): Promise<CryptoKey> {\n  const keyData = new TextEncoder().encode(password);\n  const keyMaterial = await crypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']);\n\n  const saltBuf = hexStringToBytes(salt);\n  return crypto.subtle.deriveKey(\n    {\n      name: 'PBKDF2',\n      salt: saltBuf,\n      iterations: 100000,\n      hash: 'SHA-256',\n    },\n    keyMaterial,\n    { name: 'AES-GCM', length: 256 },\n    true,\n    ['encrypt', 'decrypt'],\n  );\n}\n\nexport async function exportEncryptionKey(key: CryptoKey): Promise<JsonWebKey> {\n  return crypto.subtle.exportKey('jwk', key);\n}\n\nexport async function importEncryptionKey(key: JsonWebKey): Promise<CryptoKey> {\n  return crypto.subtle.importKey('jwk', key, 'AES-GCM', true, ['encrypt', 'decrypt']);\n}\n\nexport async function encrypt(data: BufferSource, key: CryptoKey): Promise<t.EncryptedData> {\n  const iv = generateIV();\n  const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);\n  const encrypted_base64 = await bytesToBase64(encrypted);\n  return { encrypted_base64, iv: bytesToHexString(iv) };\n}\n\nexport async function decrypt(data: t.EncryptedData, key: CryptoKey): Promise<ArrayBuffer> {\n  const encryptedBytes = await base64ToBytes(data.encrypted_base64);\n  const iv = hexStringToBytes(data.iv);\n  return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes);\n}\n\nexport async function encryptNotes(notes: t.Note[], key: CryptoKey): Promise<t.EncryptedNote[]> {\n  const start = performance.now();\n  const res: t.EncryptedNote[] = [];\n  for (const note of notes) {\n    res.push(await encryptNote(note, key));\n  }\n  if (res.length) log(`encrypted ${res.length} notes in ${performance.now() - start}ms`);\n  return res;\n}\n\nexport async function encryptNote(note: t.Note, key: CryptoKey): Promise<t.EncryptedNote> {\n  const data = new TextEncoder().encode(JSON.stringify(note));\n  const encrypted = await encrypt(data, key);\n  return { id: note.id, modification_date: note.modification_date, ...encrypted };\n}\n\nexport async function decryptNotes(notes: t.EncryptedNote[], key: CryptoKey): Promise<t.Note[]> {\n  const start = performance.now();\n  const res: t.Note[] = [];\n  for (const note of notes) {\n    res.push(await decryptNote(note, key));\n  }\n  if (res.length) log(`decrypted ${res.length} notes in ${performance.now() - start}ms`);\n  return res;\n}\n\nexport async function decryptNote(note: t.EncryptedNote, key: CryptoKey): Promise<t.Note> {\n  const decryptedData = await decrypt(note, key);\n  const noteString = new TextDecoder().decode(decryptedData);\n  return JSON.parse(noteString) as t.Note;\n}\n"
  },
  {
    "path": "src/client/custom.d.ts",
    "content": "declare module '*.svg' {\n  const content: string;\n  export default content;\n}\ndeclare module '*.txt' {\n  const content: string;\n  export default content;\n}\ndeclare module '*.md' {\n  const content: string;\n  export default content;\n}\n// declare module '*.svg' {\n//   const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;\n//   export default content;\n// }\n\ninterface Window {\n  unforgetContextId: string;\n}\n"
  },
  {
    "path": "src/client/hooks.tsx",
    "content": "import { useRouter, storeScrollY } from './router.jsx';\nimport React, { useCallback, useState, useEffect, useLayoutEffect } from 'react';\nimport _ from 'lodash';\n\nexport function useInterval(cb: () => void, ms: number) {\n  useEffect(() => {\n    const interval = setInterval(cb, ms);\n    return () => clearInterval(interval);\n  }, []);\n}\n\nexport function useCallbackCancelEvent(cb: () => any, deps: React.DependencyList): (e?: React.UIEvent) => void {\n  return useCallback((e?: React.UIEvent) => {\n    if (e) {\n      e.preventDefault();\n      e.stopPropagation();\n    }\n    cb();\n  }, deps);\n}\n\nexport function useClickWithoutDrag(cb: React.MouseEventHandler): {\n  onClick: React.MouseEventHandler;\n  onMouseDown: React.MouseEventHandler;\n} {\n  const [mouseDownPos, setMouseDownPos] = useState<[number, number] | undefined>();\n  const onMouseDown = useCallback((e: React.MouseEvent) => {\n    setMouseDownPos([e.clientX, e.clientY]);\n  }, []);\n  const onClick = useCallback(\n    (e: React.MouseEvent) => {\n      if (mouseDownPos) {\n        const diff = [Math.abs(e.clientX - mouseDownPos[0]), Math.abs(e.clientY - mouseDownPos[1])];\n        const dist = Math.sqrt(diff[0] ** 2 + diff[1] ** 2);\n        if (dist < 5) return cb(e);\n      }\n    },\n    [cb, mouseDownPos],\n  );\n\n  return { onClick, onMouseDown };\n}\n\nexport function useStoreAndRestoreScrollY() {\n  const { state } = useRouter();\n  useLayoutEffect(() => {\n    window.scrollTo(0, state?.scrollY ?? 0);\n\n    const storeScrollYRateLimited = _.debounce(storeScrollY, 100, { leading: false, trailing: true });\n    window.addEventListener('scroll', storeScrollYRateLimited);\n    return () => window.removeEventListener('scroll', storeScrollYRateLimited);\n  }, [state?.index]);\n}\n"
  },
  {
    "path": "src/client/icons.ts",
    "content": "export { default as addWhite } from '../../public/icons/add-white.svg';\nexport { default as archiveEmpty } from '../../public/icons/archive-empty.svg';\nexport { default as archiveEmptyWhite } from '../../public/icons/archive-empty-white.svg';\nexport { default as archiveFilled } from '../../public/icons/archive-filled.svg';\nexport { default as archiveFilledWhite } from '../../public/icons/archive-filled-white.svg';\n// export { default as bulletpointWhite } from '../../public/icons/bulletpoint-white.svg';\nexport { default as cycleListWhite } from '../../public/icons/cycle-list-white.svg';\n// export { default as checkboxList } from '../../public/icons/checkbox-list.svg';\nexport { default as check } from '../../public/icons/check.svg';\nexport { default as checkWhite } from '../../public/icons/check-white.svg';\nexport { default as hidePinnedWhite } from '../../public/icons/hide-pinned-white.svg';\nexport { default as hidePinnedWhite2 } from '../../public/icons/hide-pinned-white-2.svg';\nexport { default as info } from '../../public/icons/info.svg';\nexport { default as logOut } from '../../public/icons/log-out.svg';\nexport { default as logIn } from '../../public/icons/log-in.svg';\nexport { default as menuWhite } from '../../public/icons/menu-white.svg';\nexport { default as notes } from '../../public/icons/notes.svg';\nexport { default as pinEmpty } from '../../public/icons/pin-empty.svg';\nexport { default as pinEmptyWhite } from '../../public/icons/pin-empty-white.svg';\nexport { default as pinFilled } from '../../public/icons/pin-filled.svg';\nexport { default as pinFilledWhite } from '../../public/icons/pin-filled-white.svg';\nexport { default as refreshCcw } from '../../public/icons/refresh-ccw.svg';\nexport { default as upgrade } from '../../public/icons/upgrade.svg';\nexport { default as searchWhite } from '../../public/icons/search-white.svg';\nexport { default as showPinnedWhite } from '../../public/icons/show-pinned-white.svg';\nexport { default as trashWhite } from '../../public/icons/trash-white.svg';\nexport { default as user } from '../../public/icons/user.svg';\nexport { default as xWhite } from '../../public/icons/x-white.svg';\nexport { default as googleKeep } from '../../public/icons/google-keep.svg';\nexport { default as export } from '../../public/icons/export.svg';\nexport { default as import } from '../../public/icons/import.svg';\nexport { default as chevronUpWhite } from '../../public/icons/chevron-up-white.svg';\nexport { default as chevronUpDoubleWhite } from '../../public/icons/chevron-up-double-white.svg';\nexport { default as chevronDownWhite } from '../../public/icons/chevron-down-white.svg';\nexport { default as chevronDownDoubleWhite } from '../../public/icons/chevron-down-double-white.svg';\n// export { default as selectCircleWhite } from '../../public/icons/select-circle-white.svg';\nexport { default as circleSelectWhite } from '../../public/icons/circle-select-white.svg';\nexport { default as circleDeselectWhite } from '../../public/icons/circle-deselect-white.svg';\n"
  },
  {
    "path": "src/client/index.tsx",
    "content": "import type * as t from '../common/types.js';\nimport { createRoot } from 'react-dom/client';\nimport * as storage from './storage.js';\nimport { setUpManualScrollRestoration, patchHistory } from './router.jsx';\nimport React from 'react';\nimport App from './App.jsx';\nimport { postToServiceWorker } from './clientToServiceWorkerApi.js';\nimport * as appStore from './appStore.jsx';\nimport * as actions from './appStoreActions.jsx';\nimport { CACHE_VERSION } from '../common/util.js';\nimport log from './logger.js';\nimport { sync, syncInInterval, addSyncEventListener, type SyncEvent } from './sync.js';\nimport * as b from './cross-context-broadcast.js';\nimport { v4 as uuid } from 'uuid';\n\nasync function setup() {\n  // Set up unique context id.\n  window.unforgetContextId = uuid();\n\n  // Set up broadcast.\n  b.init();\n  b.addListener(handleBroadcastMessage);\n\n  // Set up storage before registering the service worker.\n  // Because the service worker itself will try to set up the storage too.\n  await storage.getStorage();\n\n  // Initialize app store.\n  // Must do before registering service worker because we need to update\n  // the appStore in reaction to messages from the service worker.\n  await actions.initAppStore();\n  await actions.makeSureConsistentUserAndCookie();\n\n  await registerServiceWorker();\n\n  // // Tell the service worker there's a new window.\n  // await postToServiceWorker({ command: 'newClient' });\n\n  // Request sync status from service worker.\n  // await postToServiceWorker({ command: 'sendSyncStatus' });\n\n  // Sync online status.\n  function onlineChanged() {\n    appStore.update(app => {\n      app.online = navigator.onLine;\n    });\n  }\n  window.addEventListener('online', onlineChanged);\n  window.addEventListener('offline', onlineChanged);\n\n  // Listen to sync events.\n  addSyncEventListener(handleSyncEvent);\n\n  // Sync online status periodically.\n  setInterval(onlineChanged, 5000);\n\n  // Sync when online.\n  window.addEventListener('online', () => {\n    sync();\n    // postToServiceWorker({ command: 'sync' });\n  });\n\n  // Check for app updates when page becomes visible.\n  window.addEventListener('visibilitychange', function visibilityChanged() {\n    if (document.visibilityState === 'visible') {\n      actions.checkAppUpdate();\n    }\n  });\n\n  // Check for app updates when online.\n  window.addEventListener('online', actions.checkAppUpdate);\n\n  // Check for app updates periodically.\n  setInterval(actions.checkAppUpdate, 10 * 1000);\n\n  // Initial check for app updates.\n  actions.checkAppUpdate();\n\n  // Update queue count periodically.\n  setInterval(actions.updateQueueCount, 3 * 1000);\n\n  // Notify user if app updated.\n  actions.notifyIfAppUpdated();\n\n  // Manual scroll restoration.\n  setUpManualScrollRestoration();\n\n  // Patch history required for our router.\n  patchHistory();\n\n  // Sync in interval\n  syncInInterval();\n\n  const root = createRoot(document.getElementById('app')!);\n  root.render(<App />);\n}\n\nasync function registerServiceWorker() {\n  if (!('serviceWorker' in navigator)) {\n    log.error('window: service workers are not supported.');\n    alert('Your browser does not support service workers. Please use another browser.');\n    return;\n  }\n\n  navigator.serviceWorker.addEventListener('message', event => {\n    log(`window: received message from service worker`, event.data);\n    handleServiceWorkerMessage(event.data);\n  });\n\n  try {\n    await navigator.serviceWorker.register('/serviceWorker.js');\n    log('window: service worker registration successful');\n  } catch (error) {\n    actions.showMessage('Failed to register service worker: ' + (error as Error).message, { type: 'error' });\n    log.error((error as Error).message);\n  }\n\n  // // Sometimes the service worker just gets disabled on iphone. I don't know why.\n  // // Here, we try to register every 5s. According to MDN, it'll automatically\n  // // check if there's already a registration.\n  // setInterval(registerServiceWorkerHelper, 5000);\n}\n\nasync function handleServiceWorkerMessage(message: t.ServiceWorkerToClientMessage) {\n  switch (message.command) {\n    case 'serviceWorkerActivated': {\n      if (message.cacheVersion > CACHE_VERSION) {\n        log(`window: require a page refresh to upgrade from ${CACHE_VERSION} to ${message.cacheVersion}`);\n        actions.requireAppUpdate();\n      }\n\n      break;\n    }\n    case 'error': {\n      actions.showMessage(message.error, { type: 'error' });\n      break;\n    }\n\n    default:\n      console.log('Unknown message', message);\n  }\n}\n\nasync function handleSyncEvent(event: SyncEvent) {\n  switch (event.type) {\n    case 'error': {\n      actions.showMessage(event.error.message, { type: 'error' });\n      break;\n    }\n    case 'mergedNotes': {\n      b.broadcast({ type: 'notesInStorageChanged' });\n      break;\n    }\n    case 'syncStatus': {\n      appStore.update(app => {\n        app.syncing = event.syncing;\n      });\n      break;\n    }\n    case 'unauthorized': {\n      await actions.resetUser();\n      b.broadcast({ type: 'refreshPage' });\n      window.location.reload();\n      break;\n    }\n  }\n}\n\nfunction handleBroadcastMessage(message: t.BroadcastChannelMessage) {\n  switch (message.type) {\n    case 'notesInStorageChanged': {\n      break; // Will listen to and handle this in specific pages.\n    }\n\n    case 'refreshPage': {\n      window.location.reload();\n      break;\n    }\n  }\n}\n\nwindow.onload = setup;\n"
  },
  {
    "path": "src/client/logger.ts",
    "content": "import * as api from './api.js';\n\nfunction log(...args: any[]) {\n  if (Number(process.env.LOG_TO_CONSOLE)) {\n    console.log(...args);\n  }\n  if (Number(process.env.FORWARD_LOGS_TO_SERVER)) {\n    api.post('/api/log', { message: stringify(...args) });\n  }\n}\n\nlog.error = function error(...args: any[]) {\n  console.error(...args);\n  if (Number(process.env.FORWARD_ERRORS_TO_SERVER)) {\n    api.post('/api/error', { message: stringify(...args) });\n  }\n};\n\nfunction stringify(...args: any[]): string {\n  let strs = [];\n  for (const arg of args) {\n    if (typeof arg === 'string') {\n      strs.push(arg);\n    } else if (arg instanceof Error) {\n      strs.push(arg.toString());\n    } else {\n      strs.push(JSON.stringify(arg));\n    }\n  }\n  return strs.join(' ');\n}\n\nexport default log;\n"
  },
  {
    "path": "src/client/normalize.css",
    "content": "/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n   ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n  line-height: 1.15; /* 1 */\n  -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n   ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n  margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n  display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n  font-size: 2em;\n  margin: 0.67em 0;\n}\n\n/* Grouping content\n   ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n  box-sizing: content-box; /* 1 */\n  height: 0; /* 1 */\n  overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n  font-family: monospace, monospace; /* 1 */\n  font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n   ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n  background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n  border-bottom: none; /* 1 */\n  text-decoration: underline; /* 2 */\n  text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n  font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n  font-family: monospace, monospace; /* 1 */\n  font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n  font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\nsub {\n  bottom: -0.25em;\n}\n\nsup {\n  top: -0.5em;\n}\n\n/* Embedded content\n   ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n  border-style: none;\n}\n\n/* Forms\n   ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  font-family: inherit; /* 1 */\n  font-size: 100%; /* 1 */\n  line-height: 1.15; /* 1 */\n  margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput {\n  /* 1 */\n  overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect {\n  /* 1 */\n  text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type='button'],\n[type='reset'],\n[type='submit'] {\n  -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type='button']::-moz-focus-inner,\n[type='reset']::-moz-focus-inner,\n[type='submit']::-moz-focus-inner {\n  border-style: none;\n  padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type='button']:-moz-focusring,\n[type='reset']:-moz-focusring,\n[type='submit']:-moz-focusring {\n  outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n  padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n *    `fieldset` elements in all browsers.\n */\n\nlegend {\n  box-sizing: border-box; /* 1 */\n  color: inherit; /* 2 */\n  display: table; /* 1 */\n  max-width: 100%; /* 1 */\n  padding: 0; /* 3 */\n  white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n  vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n  overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type='checkbox'],\n[type='radio'] {\n  box-sizing: border-box; /* 1 */\n  padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type='number']::-webkit-inner-spin-button,\n[type='number']::-webkit-outer-spin-button {\n  height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type='search'] {\n  -webkit-appearance: textfield; /* 1 */\n  outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type='search']::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n  -webkit-appearance: button; /* 1 */\n  font: inherit; /* 2 */\n}\n\n/* Interactive\n   ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n  display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n  display: list-item;\n}\n\n/* Misc\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n  display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n  display: none;\n}\n"
  },
  {
    "path": "src/client/notes/about.md",
    "content": "# About\n\nUnforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.\n\nUnforget is MIT Licensed.\n\nCheck out our [GitHub](https://github.com/computing-den/unforget).\n\nContact us at sean@computing-den.com\n\n\n\n# Release Notes\n\n### 25 October 2025\n- Moved notifications to the bottom of the page\n- Fixed a bug where pasting text would overwrite current line\n\n### 02 August 2025\n- Show selection during search\n- Escape always cancels search first and then selection\n- Fixed issue with the header jumping down on IOS\n- Show the pin icon on pinned notes during selection\n- Auto format pasted text (convert to list/checkbox, fix indentation, etc.)\n- More bug fixes\n\n### 17 August 2024\n- Bug fixes: shortcut conflicts\n\n### 16 August 2024\n- Select and move notes\n- Import Unforget's own exported JSON\n- Improved sync for mobile devices\n\n### 22 June 2024\n- Import Apple Notes\n- Include labels as tags when importing Google Keep\n- Show creation and modification dates of notes\n- Fix a rare syncing issue\n\n### 13 June 2024\n- Import from Standard Notes\n- Warn when picking a weak password\n- Do not allow empty password\n- [Explain organization and workflow of notes as well as security and privacy](https://github.com/computing-den/unforget/blob/master/README.md)\n- Fix some typos\n"
  },
  {
    "path": "src/client/notes/export.md",
    "content": "# Export as JSON\n\n[Click here](#export-json) to export notes in JSON format.\n\nThe JSON file will contain an array of notes where the type of each note is:\n\n```\ntype Note = {\n\n  // UUID version 4\n  id: string;\n\n  // Deleted notes have null text.\n  text: string | null;\n\n  // In ISO 8601 format\n  creation_date: string;\n  \n  // In ISO 8601 format\n  modification_date: string;\n  \n  // 0 means deleted, 1 means not deleted\n  not_deleted: number;\n  \n  // 0 means archived, 1 means not archived\n  not_archived: number;\n  \n  // 0 means not pinned, 1 means pinned\n  pinned: number;\n\n  // A higher number means higher on the list.\n  // Usually, by default it's milliseconds since the epoch\n  order: number;\n\n}\n```\n"
  },
  {
    "path": "src/client/notes/import.md",
    "content": "\n*Note: the import process is done entirely on your device to preserve your privacy.*\n\n# Google Keep\n\n1. Go to [Google Takout](https://takeout.google.com/)\n2. Select only Keep's data for export\n   *it'll be ready for download in a few minutes*\n3. [Click here](#keep) to import notes from the zip file\n   - [ ] include labels as tags\n\n\n# Apple Notes\n\n1. Go to https://privacy.apple.com/\n2. Request a copy of your data\n3. Select only iCloud Notes for export\n4. Complete request\n   *it'll be ready for download in a couple of days*\n5. [Click here](#apple) to import notes from the zip file\n   - [ ] include folder names as tags\n\n# Standard Notes\n\n1. Open Standard Notes app or [website](https://app.standardnotes.com/)\n2. Select your notes and press Export to download the zip file\n3. [Click here](#standard) to import notes from the zip file\n\n# Unforget\n\n1. From the menu, pick export and download the JSON file\n2. [Click here](#unforget) to import notes from the JSON file\n\n\n# CLI and Public APIs\n\nPlease check out the [GitHub page](https://github.com/computing-den/unforget?tab=readme-ov-file#public-apis---write-your-own-client) for the instructions.\n"
  },
  {
    "path": "src/client/notes/welcome1.md",
    "content": "# Welcome!\n\nUnforget is a minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js) featuring:\n\n- [x] Offline first\n- [x] Privacy first\n- [x] Progressive web app\n- [x] Open source MIT License\n- [x] End-to-end encrypted sync\n- [x] Desktop, Mobile, Web\n- [x] Markdown support\n- [x] Self hosted and cloud options\n- [x] One-click data export as JSON\n- [x] Optional one-click installation\n- [x] Public APIs, create your own client\n- [x] Import Google Keep\n- [x] Import Apple Notes\n- [x] Import Standard Notes\n\n\n*Unforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.*\n\n*Contact us at sean@computing-den.com*\n\n# Easy Signup\n\n[Sign up](/login) for free to back up your notes safely to the cloud fully encrypted and sync across devices.\n\n*No email or phone required.*\n\n# Optional installation\n\nUse it directly in your browser or install:\n\n| Browser         | Installation                |\n|-----------------|-----------------------------|\n| Chrome          | Install icon in the URL bar |\n| Edge            | Install icon in the URL bar |\n| Android Browser | Menu → Add to Home Screen   |\n| Safari Desktop  | Share → Add to Dock         |\n| Safari iOS      | Share → Add to Home Screen  |\n| Firefox Desktop | *cannot install*            |\n| Firefox Android | Install icon in the URL bar |\n\n# Organization and Workflow\n\n---\n\nThe notes are organized **chronologically**, with pinned notes displayed at the top.\n\nThis organization has proven very effective despite its simplicity. The **search is very fast** (and done offline), allowing you to quickly narrow down notes by entering a few phrases. Additionally, you can search for non-alphabetic characters, enabling the use of **tags** such as #idea, #project, #work, #book, etc.\n\nThere is **no limit** to the size of a note. For larger notes, you can insert a `---` on a line by itself to **collapse** the rest of the note.\n\nNotes are **immediately saved** as you type and synced every few seconds.\n\nIf you edit a note from two devices and a **conflict** occurs during sync, the most recent edit will take precedence.\n\n# Security and Privacy\n\nUnforget does not receive or store any personal data. No email or phone is required to sign up. As long as you pick a strong password, your notes will be stored in the cloud fully encrypted and safe.\n\nOnly your username and note modification dates are visible to Unforget servers.\n\n# Text Formatting\n\nThe main differences with the [Github flavored markdown](https://github.github.com/gfm/) are:\n- If the first line of a note is followed by a blank line, it is a H1 header.\n- Anything after the first horizontal rule `---` in a note will be hidden and replaced with a \"show more\" button that will expand the note.\n\n~~~\n# H1 header\n## H2 header\n### H3 header\n#### H4 header\n##### H5 header\n###### H6 header\n\n*This is italic.*.\n\n**This is bold.**.\n\n***This is bold and italic.***\n\n~~This is strikethrough~~\n\n\n- This is a bullet point\n- Another bullet point\n  - Inner bullet point\n- [ ] This is a checkbox\n  And more text related to the checkbox.\n\n1. This is an ordered list item\n2. And another one\n\n[this is a link](https://unforget.computing-den.com)\n\nInline `code` using back-ticks.\n\nBlock of code:\n\n```javascript\nfunction plusOne(a) {\n  return a + 1;\n}\n```\n\n\n| Tables        | Are           | Cool  |\n| ------------- |:-------------:| -----:|\n| col 3 is      | right-aligned | $1600 |\n| col 2 is      | centered      |   $12 |\n\n\nHorizontal rule:\n\n---\n\n\n~~~\n"
  },
  {
    "path": "src/client/router.tsx",
    "content": "import React, {\n  useMemo,\n  useCallback,\n  useContext,\n  createContext,\n  useDeferredValue,\n  useSyncExternalStore,\n  Suspense,\n} from 'react';\nimport log from './logger.js';\n\nexport type HistoryState = { index: number; scrollY?: number };\n\nexport type Route = {\n  path: string;\n  element: React.ReactNode | ((match: RouteMatch) => React.ReactNode);\n  loader?: Loader;\n};\n\nexport type Loader = (match: RouteMatch) => Promise<any>;\n\nexport type RouterCtxType = {\n  match?: RouteMatch;\n  search: string;\n  pathname: string;\n  state: HistoryState;\n  loaderData?: WrappedPromise<any>;\n};\n\nexport type RouterLoadingCtxType = {\n  isLoading: boolean;\n};\n\nexport type Params = Record<string, string>;\n// export type FallbackArgs = {isLoading: boolean};\n\nexport type RouteMatch = { route: Route; params: Params; pathname: string };\n\ntype WrappedPromise<T> = { read: () => T; status: 'pending' | 'success' | 'error' };\n\nconst RouterCtx = createContext<RouterCtxType>({ pathname: '/', search: '', state: { index: 0 } });\nconst RouterLoadingCtx = createContext<RouterLoadingCtxType>({ isLoading: false });\n// const dataLoaderCache = new Map<string, WrappedPromise<any>>();\n\nexport function Router(props: { routes: Route[]; fallback: React.ReactNode }) {\n  const pathname = useWindowLocationPathname();\n  const search = useWindowLocationSearch();\n  const state = useWindowHistoryState();\n  const match = useMemo(() => matchRoute(pathname, props.routes), [pathname, props.routes]);\n  const routerCtxValue: RouterCtxType = useMemo(() => {\n    log('Creating router context for ', pathname);\n    return {\n      match,\n      pathname,\n      search,\n      state,\n      loaderData: match?.route.loader && wrapPromise(match.route.loader(match)),\n    };\n  }, [match, pathname, search, state]);\n  const deferredCtxValue = useDeferredValue(routerCtxValue);\n\n  const routerLoadingCtx = {\n    isLoading: deferredCtxValue !== routerCtxValue,\n  };\n  log('router: isLoading: ', routerLoadingCtx.isLoading);\n\n  // NOTE It is important that Suspense is not above the Router, otherwise when an element suspends,\n  // the useMemo's and useState's of the Router will be called more than once.\n  return (\n    <Suspense fallback={props.fallback}>\n      <RouterCtx.Provider value={deferredCtxValue}>\n        <RouterLoadingCtx.Provider value={routerLoadingCtx}>\n          <Suspender>{deferredCtxValue.match?.route.element}</Suspender>\n        </RouterLoadingCtx.Provider>\n      </RouterCtx.Provider>\n    </Suspense>\n  );\n}\n\nexport function Link(props: { to: string; className?: string; children: React.ReactNode }) {\n  const clickCb = useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      history.pushState(null, '', props.to);\n    },\n    [props.to],\n  );\n  return (\n    <a href={props.to} onClick={clickCb} className={props.className}>\n      {props.children}\n    </a>\n  );\n}\n\nfunction Suspender(props: { children: Route['element'] }) {\n  const { loaderData, match } = useContext(RouterCtx);\n  loaderData?.read(); // It'll throw a promise if not yet resolved.\n  if (typeof props.children === 'function') {\n    return props.children(match!);\n  } else {\n    return props.children;\n  }\n}\n\nexport function useRouterLoading(): RouterLoadingCtxType {\n  return useContext(RouterLoadingCtx);\n}\n\nexport function useRouter(): RouterCtxType {\n  return useContext(RouterCtx);\n}\n\nfunction matchRoute(pathname: string, routes: Route[]): RouteMatch | undefined {\n  const actualParts = pathname.split('/');\n  for (const route of routes) {\n    const expectedParts = route.path.split('/');\n    const params = matchParts(actualParts, expectedParts);\n    if (params) return { route, params, pathname };\n  }\n}\n\nfunction matchParts(actualParts: string[], expectedParts: string[]): Params | undefined {\n  if (actualParts.length !== expectedParts.length) return;\n\n  const params: Params = {};\n  for (let i = 0; i < actualParts.length; i++) {\n    if (expectedParts[i].startsWith(':')) {\n      params[expectedParts[i].substring(1)] = actualParts[i];\n    } else if (actualParts[i] !== expectedParts[i]) {\n      return;\n    }\n  }\n  return params;\n}\n\nconst eventPopstate = 'popstate';\nconst eventPushState = 'pushstate';\nconst eventReplaceState = 'replacestate';\nconst eventHashchange = 'hashchange';\nconst events = [eventPopstate, eventPushState, eventReplaceState, eventHashchange];\n\n// export const navigate = (to: string, opts?: { replace?: boolean; state?: any }) =>\n//   opts?.replace ? window.history.replaceState(opts?.state, '', to) : window.history.pushState(opts?.state, '', to);\n\nexport const useWindowLocationPathname = () => useSyncExternalStore(subscribeToHistoryUpdates, getLocationPathname);\nfunction getLocationPathname(): string {\n  return window.location.pathname;\n}\n\nexport const useWindowLocationSearch = () => useSyncExternalStore(subscribeToHistoryUpdates, getLocationSearch);\nfunction getLocationSearch(): string {\n  return window.location.search;\n}\n\nexport const useWindowHistoryState = () => useSyncExternalStore(subscribeToHistoryUpdates, getHistoryState);\nfunction getHistoryState(): HistoryState {\n  return window.history.state;\n}\n\nfunction subscribeToHistoryUpdates(callback: () => void) {\n  for (const event of events) {\n    window.addEventListener(event, callback);\n  }\n  return () => {\n    for (const event of events) {\n      window.removeEventListener(event, callback);\n    }\n  };\n}\n\nfunction wrapPromise<T>(promise: Promise<T>): WrappedPromise<T> {\n  let status: 'pending' | 'success' | 'error' = 'pending';\n  let error: Error | undefined;\n  let value: T | undefined;\n\n  promise\n    .then(v => {\n      status = 'success';\n      value = v;\n    })\n    .catch(e => {\n      status = 'error';\n      error = e;\n    });\n\n  return {\n    get status() {\n      return status;\n    },\n    read() {\n      if (status === 'pending') throw promise;\n      if (status === 'error') throw error!;\n      return value!;\n    },\n  };\n}\n\nfunction assertHistoryStateType(data: any) {\n  if (data !== null && data !== undefined && typeof data !== 'object')\n    throw new Error('Please provide an object as history state');\n}\n\nlet origReplaceState: (data: any, unused: string, url?: string | URL | null) => void;\nlet origPushState: (data: any, unused: string, url?: string | URL | null) => void;\n\n/**\n * For some reason the browser sometimes does it properly and sometimes not.\n * Probably due to the whole React suspense and the delay in page transition.\n * So, we do it manually.\n */\nexport function setUpManualScrollRestoration() {\n  window.history.scrollRestoration = 'manual';\n}\n\nexport function storeScrollY() {\n  const state: HistoryState = { ...window.history.state, scrollY: window.scrollY };\n  origReplaceState.call(window.history, state, ''); // Won't dispatch any events.\n}\n\n/**\n * Monkey patch window.history to dispatch 'pushstate' and 'replacestate' events.\n * Also keep extra data the history state:\n *   index: number so that for example we know if history.back() can be called.\n */\nexport function patchHistory() {\n  origPushState = window.history.pushState;\n  origReplaceState = window.history.replaceState;\n  window.history.pushState = function pushState(data: any, unused: string, url?: string | URL | null) {\n    try {\n      log(`pushState (patched) ${url} started`);\n      assertHistoryStateType(data);\n      const state: HistoryState = { ...data, index: window.history.state.index + 1 };\n      origPushState.call(this, state, unused, url);\n      const event = new Event(eventPushState);\n      window.dispatchEvent(event);\n      log(`pushState (patched) ${url} done`);\n    } catch (error) {\n      log.error(error);\n    }\n  };\n  window.history.replaceState = function replaceState(data: any, unused: string, url?: string | URL | null) {\n    try {\n      assertHistoryStateType(data);\n      const state: HistoryState = { ...data, index: window.history.state.index };\n      origReplaceState.call(this, state, unused, url);\n      const event = new Event(eventReplaceState);\n      window.dispatchEvent(event);\n    } catch (error) {\n      log.error(error);\n    }\n  };\n\n  // Initialize history.state\n  if (!Number.isFinite(window.history.state?.index)) {\n    origReplaceState.call(window.history, { index: 0 }, '');\n  }\n}\n"
  },
  {
    "path": "src/client/serviceWorker.ts",
    "content": "// Default type of `self` is `WorkerGlobalScope & typeof globalThis`\n// https://github.com/microsoft/TypeScript/issues/14877\ndeclare var self: ServiceWorkerGlobalScope;\n\nimport * as storage from './storage.js';\n// import { sync, syncInInterval, requireQueueSync, syncDebounced, isSyncing } from './serviceWorkerSync.js';\nimport { postToClients } from './serviceWorkerToClientApi.js';\nimport type { ClientToServiceWorkerMessage } from '../common/types.js';\nimport { CACHE_VERSION, ServerError } from '../common/util.js';\nimport log from './logger.js';\n\nconst CACHE_NAME = `unforget-${CACHE_VERSION}`;\nconst APP_STATIC_RESOURCES = ['/', '/style.css', '/index.js', '/manifest.json', '/icon-256x256.png'];\n\nself.addEventListener('install', event => {\n  // The promise that skipWaiting() returns can be safely ignored.\n  // Causes a newly installed service worker to progress into the activating state,\n  // regardless of whether there is already an active service worker.\n  self.skipWaiting();\n  event.waitUntil(installServiceWorker());\n});\n\n// NOTE: The activate event is triggered only once after the install event.\nself.addEventListener('activate', event => {\n  event.waitUntil(activateServiceWorker());\n});\n\n// On fetch, intercept server requests\n// and respond with cached responses instead of going to network\nself.addEventListener('fetch', event => {\n  event.respondWith(handleFetch(event));\n});\n\n// Listen to messages from window.\nself.addEventListener('message', async event => {\n  try {\n    const message = event.data as ClientToServiceWorkerMessage;\n    log('service worker: received message: ', message);\n    if (event.source instanceof Client) {\n      await handleClientMessage(event.source, message);\n    }\n  } catch (error) {\n    log.error(error);\n  }\n});\n\nasync function installServiceWorker() {\n  log('service worker: installing...');\n\n  // Cache the static resources.\n  const cache = await caches.open(CACHE_NAME);\n  cache.addAll(APP_STATIC_RESOURCES);\n\n  log('service worker: install done.');\n}\n\n// NOTE: The activate event is triggered only once after the install event.\nasync function activateServiceWorker() {\n  log('service worker: activating...');\n\n  // Delete old caches.\n  const names = await caches.keys();\n  await Promise.all(\n    names.map(name => {\n      if (name !== CACHE_NAME) {\n        return caches.delete(name);\n      }\n    }),\n  );\n\n  // Set up storage.\n  await storage.getStorage();\n\n  // Take control of the clients and refresh them.\n  // The refresh is necessary if the activate event was triggered by updateApp().\n  await self.clients.claim();\n  log('service worker: activated.');\n\n  log('service worker: informing clients of serviceWorkerActivated with cacheVersion', CACHE_VERSION);\n  postToClients({ command: 'serviceWorkerActivated', cacheVersion: CACHE_VERSION });\n}\n\nasync function handleFetch(event: FetchEvent): Promise<Response> {\n  const url = new URL(event.request.url);\n  const { mode, method } = event.request;\n  log('service worker fetch: ', mode, method, url.pathname);\n\n  let response: Response | undefined;\n\n  // As a single page app, direct app to always go to cached home page.\n  if (mode === 'navigate') {\n    response = await caches.match('/');\n  } else if (method === 'GET' && !Number(process.env.DISABLE_CACHE)) {\n    const cache = await caches.open(CACHE_NAME);\n    response = await cache.match(event.request);\n  }\n\n  if (response) return response;\n\n  try {\n    response = await fetch(event.request, {\n      headers: new Headers([...event.request.headers, ['X-Service-Worker-Cache-Version', String(CACHE_VERSION)]]),\n    });\n  } catch (error) {\n    return Response.error();\n  }\n\n  if (!response.ok) {\n    try {\n      const clonedResponse = response.clone();\n      const error = ServerError.fromJSON(await clonedResponse.json());\n      if (error.type === 'app_requires_update') {\n        await self.registration.update();\n      }\n    } catch (error) {\n      log.error(error);\n    }\n  }\n\n  return response;\n}\n\nasync function handleClientMessage(client: Client, message: ClientToServiceWorkerMessage) {\n  // NOTE: Nothing to handle any more.\n  // switch (message.command) {\n  //   // case 'update': {\n  //   //   await self.registration.update();\n  //   //   break;\n  //   // }\n  //   // case 'newClient': {\n  //   //   // syncInInterval();\n  //   //   break;\n  //   // }\n  //   default:\n  //     log('Unknown message: ', message);\n  // }\n}\n"
  },
  {
    "path": "src/client/serviceWorkerToClientApi.ts",
    "content": "// Default type of `self` is `WorkerGlobalScope & typeof globalThis`\n// https://github.com/microsoft/TypeScript/issues/14877\ndeclare var self: ServiceWorkerGlobalScope;\nimport type { ServiceWorkerToClientMessage } from '../common/types.js';\nimport log from './logger.js';\n\nexport function postToClient(client: Client, message: ServiceWorkerToClientMessage) {\n  client.postMessage(message);\n}\n\nexport async function postToClients(message: ServiceWorkerToClientMessage, options?: { exceptClientIds?: string[] }) {\n  try {\n    const clients = await self.clients.matchAll();\n    for (const client of clients) {\n      if (!options?.exceptClientIds?.includes(client.id)) {\n        postToClient(client, message);\n      }\n    }\n  } catch (error) {\n    log.error(error);\n  }\n}\n"
  },
  {
    "path": "src/client/storage.ts",
    "content": "import type * as t from '../common/types.js';\nimport * as cutil from '../common/util.jsx';\nimport { exportEncryptionKey, importEncryptionKey } from './crypto.js';\nimport _ from 'lodash';\nimport log from './logger.js';\n\nlet _db: IDBDatabase | undefined;\n\nexport const DB_NAME = 'unforget';\nexport const NOTES_STORE = 'notes';\nexport const NOTES_STORE_ORDER_INDEX = 'orderIndex';\nexport const NOTES_QUEUE_STORE = 'notesQueue';\nexport const SETTINGS_STORE = 'settings';\n\ntype SaveNoteQueueItem = { note: t.Note; resolve: () => void; reject: (error: Error) => any };\n\nlet saveNoteQueue: SaveNoteQueueItem[] = [];\nlet saveNoteQueueActive: boolean = false;\n\nexport async function getStorage(): Promise<IDBDatabase> {\n  if (!_db) {\n    _db = await new Promise<IDBDatabase>((resolve, reject) => {\n      log('setting up storage');\n      const dbOpenReq = indexedDB.open(DB_NAME, 53);\n\n      dbOpenReq.onerror = () => {\n        _db = undefined;\n        reject(dbOpenReq.error);\n      };\n\n      dbOpenReq.onupgradeneeded = e => {\n        // By comparing e.oldVersion with e.newVersion, we can perform only the actions needed for the upgrade.\n        if (e.oldVersion < 52) {\n          const notesStore = dbOpenReq.result.createObjectStore(NOTES_STORE, { keyPath: 'id' });\n          notesStore.createIndex(NOTES_STORE_ORDER_INDEX, ['order']);\n          dbOpenReq.result.createObjectStore(NOTES_QUEUE_STORE, { keyPath: 'id' });\n          dbOpenReq.result.createObjectStore(SETTINGS_STORE);\n        }\n        if (e.oldVersion < 53) {\n          const notesStore = dbOpenReq.transaction!.objectStore(NOTES_STORE);\n          notesStore.deleteIndex(NOTES_STORE_ORDER_INDEX);\n          notesStore.createIndex(NOTES_STORE_ORDER_INDEX, ['not_archived', 'not_deleted', 'pinned', 'order']);\n        }\n      };\n\n      dbOpenReq.onsuccess = () => {\n        resolve(dbOpenReq.result);\n      };\n    });\n\n    _db.onversionchange = () => {\n      _db?.close();\n      log('new version of database is ready. Closing the database ...');\n    };\n    _db.onclose = () => {\n      _db = undefined;\n      log('database is closed');\n    };\n    _db.onabort = () => {\n      log('database is aborted');\n    };\n    _db.onerror = error => {\n      log.error(error);\n    };\n  }\n\n  return _db;\n}\n\nexport async function transaction<T>(\n  storeNames: string | Iterable<string>,\n  mode: IDBTransactionMode,\n  callback: (tx: IDBTransaction) => T,\n): Promise<T> {\n  const db = await getStorage();\n  return new Promise<T>(async (resolve, reject) => {\n    let tx: IDBTransaction | undefined;\n    try {\n      tx = db.transaction(storeNames, mode);\n      let res: T;\n      tx.oncomplete = () => {\n        // log('transaction succeeded');\n        resolve(res);\n      };\n      tx.onerror = () => {\n        log('transaction error', tx!.error);\n        reject(tx!.error);\n      };\n      res = await callback(tx);\n    } catch (error) {\n      try {\n        tx?.abort();\n      } catch (error2) {\n        log.error('transaction abort() failed', error2);\n      } finally {\n        reject(error);\n      }\n    }\n  });\n}\n\nexport async function saveNote(note: t.Note) {\n  return saveNotes([note]);\n}\n\nexport async function saveNotes(notes: t.Note[]) {\n  const promises = notes.map(enqueueNote);\n  saveNextNotesInQueue(); // Don't await here.\n  await Promise.all(promises);\n}\n\nfunction enqueueNote(note: t.Note): Promise<void> {\n  return new Promise<void>((resolve, reject) => {\n    saveNoteQueue.unshift({ note, resolve, reject });\n  });\n}\n\nasync function saveNextNotesInQueue() {\n  if (saveNoteQueueActive) return;\n  if (saveNoteQueue.length === 0) return;\n\n  const items = [...saveNoteQueue];\n  saveNoteQueue.length = 0;\n\n  try {\n    await saveNoteQueueItems(items);\n    log(\n      'saved notes ',\n      items.map(item => item.note.text),\n    );\n    for (const item of items) item.resolve();\n  } catch (error) {\n    for (const item of items) item.reject(error as Error);\n  } finally {\n    saveNoteQueueActive = false;\n    saveNextNotesInQueue(); // Don't await here.\n  }\n}\n\nasync function saveNoteQueueItems(items: SaveNoteQueueItem[]) {\n  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', tx => {\n    for (const item of items) {\n      tx.objectStore(NOTES_STORE).put(item.note);\n      tx.objectStore(NOTES_QUEUE_STORE).put(createNoteHeadFromNote(item.note));\n    }\n  });\n}\n\nfunction createNoteHeadFromNote(note: t.Note): t.NoteHead {\n  return { id: note.id, modification_date: note.modification_date };\n}\n\nexport async function moveNotesUp(ids: string[]) {\n  const start = performance.now();\n  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {\n    const notesStore = tx.objectStore(NOTES_STORE);\n    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);\n    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);\n\n    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));\n    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'desc');\n\n    for (const note of notes) {\n      // Get the newer note\n      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, note.order];\n      const upperKey = [note.not_archived, note.not_deleted, note.pinned, Infinity];\n      const newerNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey, true, true));\n      const newerNoteCursorRes = await waitForDBRequest(newerNoteCursorReq);\n      const newerNote: t.Note = newerNoteCursorRes?.value;\n\n      // Skip if not found.\n      if (!newerNote) continue;\n\n      // // The newerNote may not actually be newer if it differs in not_archived, not_deleted, or pinned.\n      // if (newerNote.order <= note.order) continue;\n\n      // Don't jump over a note in the selection. In other words, the relative order of selection\n      // stays the same.\n      if (notes.find(n => n.id === newerNote.id)) continue;\n\n      // Swap the orders and set modification time.\n      [note.order, newerNote.order] = [newerNote.order, note.order];\n      note.modification_date = newerNote.modification_date = new Date().toISOString();\n\n      // Save both\n      await Promise.all([\n        waitForDBRequest(notesStore.put(note)),\n        waitForDBRequest(notesStore.put(newerNote)),\n        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(note))),\n        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(newerNote))),\n      ]);\n    }\n  });\n  log(`moveNotesUp done in ${performance.now() - start}ms`);\n}\n\nexport async function moveNotesDown(ids: string[]) {\n  const start = performance.now();\n  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {\n    const notesStore = tx.objectStore(NOTES_STORE);\n    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);\n    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);\n\n    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));\n    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'asc');\n\n    // Going in reverse order (oldest to newest notes).\n    for (const note of notes) {\n      // Get the older note.\n      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, 0];\n      const upperKey = [note.not_archived, note.not_deleted, note.pinned, note.order];\n      const olderNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey, true, true), 'prev');\n      const olderNoteCursorRes = await waitForDBRequest(olderNoteCursorReq);\n      const olderNote: t.Note = olderNoteCursorRes?.value;\n\n      // Skip if not found.\n      if (!olderNote) continue;\n\n      // Don't jump over a note in the selection. In other words, the relative order of selection\n      // stays the same.\n      if (notes.find(n => n.id === olderNote.id)) continue;\n\n      // // The olderNote may not actually be older if it differs in not_archived, not_deleted, or pinned.\n      // if (olderNote.order >= note.order) continue;\n\n      // Swap the orders and set modification time.\n      [note.order, olderNote.order] = [olderNote.order, note.order];\n      note.modification_date = olderNote.modification_date = new Date().toISOString();\n\n      // Save both\n      await Promise.all([\n        waitForDBRequest(notesStore.put(note)),\n        waitForDBRequest(notesStore.put(olderNote)),\n        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(note))),\n        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(olderNote))),\n      ]);\n    }\n  });\n  log(`moveNotesDown done in ${performance.now() - start}ms`);\n}\n\nexport async function moveNotesToTop(ids: string[]) {\n  const start = performance.now();\n  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {\n    const notesStore = tx.objectStore(NOTES_STORE);\n    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);\n    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);\n\n    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));\n    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'asc');\n\n    // Going in reverse order (oldest to newest notes).\n    // Must do this one at a time because we can't get the absolute max/min order (unless we create a new index) and\n    // some of the notes may differ in not_archived, not_deleted, and pinned.\n    for (const note of notes) {\n      // Get the newest note\n      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, 0];\n      const upperKey = [note.not_archived, note.not_deleted, note.pinned, Infinity];\n      const newestNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey), 'prev');\n      const newestNoteCursorRes = await waitForDBRequest(newestNoteCursorReq);\n      const newestNote: t.Note = newestNoteCursorRes?.value;\n\n      // Skip if not found.\n      if (!newestNote) continue;\n\n      // set the order and modification time.\n      note.order = newestNote.order + 1000;\n      note.modification_date = new Date().toISOString();\n\n      // Save\n      await Promise.all([\n        waitForDBRequest(notesStore.put(note)),\n        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(note))),\n      ]);\n    }\n  });\n  log(`moveNotesToTop done in ${performance.now() - start}ms`);\n}\n\nexport async function moveNotesToBottom(ids: string[]) {\n  const start = performance.now();\n  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {\n    const notesStore = tx.objectStore(NOTES_STORE);\n    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);\n    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);\n\n    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));\n    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'desc');\n\n    // Must do this one at a time because we can't get the absolute max/min order (unless we create a new index) and\n    // some of the notes may differ in not_archived, not_deleted, and pinned.\n    for (const note of notes) {\n      // Get the oldest note\n      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, 0];\n      const upperKey = [note.not_archived, note.not_deleted, note.pinned, Infinity];\n      const oldestNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey));\n      const oldestNoteCursorRes = await waitForDBRequest(oldestNoteCursorReq);\n      const oldestNote: t.Note = oldestNoteCursorRes?.value;\n\n      // Skip if not found.\n      if (!oldestNote) continue;\n\n      // set the order and modification time.\n      note.order = oldestNote.order - 1000;\n      note.modification_date = new Date().toISOString();\n\n      // Save\n      await Promise.all([\n        waitForDBRequest(notesStore.put(note)),\n        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(note))),\n      ]);\n    }\n  });\n  log(`moveNotesToBottom done in ${performance.now() - start}ms`);\n}\n\nexport function isSavingNote(): boolean {\n  return saveNoteQueueActive;\n}\n\nexport async function getAllNotes(): Promise<t.Note[]> {\n  const req = await transaction(\n    NOTES_STORE,\n    'readonly',\n    tx => tx.objectStore(NOTES_STORE).index(NOTES_STORE_ORDER_INDEX).getAll() as IDBRequest<t.Note[]>,\n  );\n  return req.result;\n}\n\nexport async function getNotes(opts?: {\n  limit?: number;\n  archive?: boolean;\n  hidePinnedNotes?: boolean;\n  search?: string;\n}): Promise<{ done: boolean; notes: t.Note[] }> {\n  const notes: t.Note[] = [];\n  const limit = opts?.limit;\n  let done = false;\n\n  let regexps: RegExp[] | undefined;\n  if (opts?.search) {\n    const words = opts.search.split(/\\s+/g).map(cutil.escapeRegExp);\n    regexps = words.map(word => new RegExp(word, 'i'));\n  }\n\n  await transaction([NOTES_STORE], 'readonly', async tx => {\n    return new Promise<void>((resolve, reject) => {\n      const orderIndex = tx.objectStore(NOTES_STORE).index(NOTES_STORE_ORDER_INDEX);\n      const cursorReq = orderIndex.openCursor(null, 'prev');\n      cursorReq.onerror = () => {\n        reject(cursorReq.error);\n      };\n      cursorReq.onsuccess = () => {\n        const cursor = cursorReq.result;\n        if (!cursor) {\n          // Reached the end, we're done.\n          done = true;\n          resolve();\n          return;\n        }\n\n        if (limit !== undefined && notes.length >= limit) {\n          // Reached the limit, we're done.\n          resolve();\n          return;\n        }\n\n        // There is a note.\n        const note = cursor.value as t.Note;\n\n        if (opts?.archive) {\n          // If archived notes were requested, continue until we reach the first archived note.\n          if (note.not_archived) {\n            cursor.continue();\n            return;\n          }\n        } else if (!note.not_archived) {\n          // If archived notes were not requested and we reached an archived note, we're done.\n          done = true;\n          resolve();\n          return;\n        }\n\n        if (!note.not_deleted) {\n          // If we hit a deleted note, we're done.\n          done = true;\n          resolve();\n          return;\n        }\n\n        if (opts?.hidePinnedNotes && note.pinned) {\n          // If pinned notes must be skipped, continue until the first non-pinned note.\n          cursor.continue();\n          return;\n        }\n\n        if (regexps && !regexps.every(regexp => regexp.test(note.text ?? ''))) {\n          // If there's a search phrase and it doesn't match, skip.\n          cursor.continue();\n          return;\n        }\n\n        // Found a note.\n        notes.push(note);\n        cursor.continue();\n      };\n    });\n  });\n\n  return { notes, done };\n}\n\nexport async function getNotesById(ids: string[]): Promise<(t.Note | undefined)[]> {\n  const reqs = await transaction(NOTES_STORE, 'readonly', tx => {\n    const notesStore = tx.objectStore(NOTES_STORE);\n    return ids.map(id => notesStore.get(id)) as IDBRequest<t.Note | undefined>[];\n  });\n  return reqs.map(req => req.result);\n}\n\nexport async function getNote(id: string): Promise<t.Note | undefined> {\n  return (await getNotesById([id]))[0];\n}\n\nexport async function clearAll() {\n  const db = await getStorage();\n  const storeNames = Array.from(db.objectStoreNames);\n  await transaction(storeNames, 'readwrite', tx => {\n    for (const name of storeNames) tx.objectStore(name).clear();\n  });\n}\n\n// export async function clearNotes() {\n//   const storeNames = [NOTES_STORE, NOTES_QUEUE_STORE];\n//   await transaction(storeNames, 'readwrite', tx => {\n//     for (const name of storeNames) tx.objectStore(name).clear();\n//   });\n// }\n\nexport async function waitForDBRequest<T>(req: IDBRequest<T>): Promise<T> {\n  return new Promise((resolve, reject) => {\n    req.onerror = () => {\n      reject(req.error);\n    };\n    req.onsuccess = () => {\n      resolve(req.result);\n    };\n  });\n}\n\nexport async function countQueuedNotes(): Promise<number> {\n  const res = await transaction([NOTES_QUEUE_STORE], 'readonly', tx => tx.objectStore(NOTES_QUEUE_STORE).count());\n  return res.result;\n}\n\nexport async function getSetting<T = unknown>(key: string): Promise<T | undefined> {\n  const res = await transaction([SETTINGS_STORE], 'readonly', async tx => {\n    return tx.objectStore(SETTINGS_STORE).get(key) as IDBRequest<any | undefined>;\n  });\n  return res.result;\n}\n\nexport async function setSetting(value: any, key: string) {\n  await transaction([SETTINGS_STORE], 'readwrite', async tx => {\n    tx.objectStore(SETTINGS_STORE).put(value, key);\n  });\n}\n\n/**\n * Safari (at least on iOS) has trouble serializing and deserializing CryptoKey.\n * It sets any value object that has a CryptoKey inside to null when we set it\n * in one context (window) and try to read it in another (service worker).\n * So, we export and import manually.\n */\nexport async function setUser(user: t.ClientLocalUser) {\n  const u = { ...user, encryptionKey: await exportEncryptionKey(user.encryptionKey) };\n  await setSetting(u, 'userJson');\n}\n\nexport async function getUser(): Promise<t.ClientLocalUser | undefined> {\n  const u = (await getSetting('userJson')) as any;\n  if (u) {\n    return { ...u, encryptionKey: await importEncryptionKey(u.encryptionKey) };\n  }\n}\n\nexport async function clearUser() {\n  await setSetting(undefined, 'userJson');\n}\n"
  },
  {
    "path": "src/client/style.css",
    "content": "@import './normalize.css';\n@import './common.css';\n@import './PageLayout.css';\n@import './Editor.css';\n@import './Menu.css';\n@import './Notes.css';\n@import './Notifications.css';\n\n@import './LoginPage.css';\n@import './NotesPage.css';\n@import './NotePage.css';\n"
  },
  {
    "path": "src/client/sync.ts",
    "content": "declare var self: ServiceWorkerGlobalScope;\n\nimport type * as t from '../common/types.js';\nimport { ServerError, isNoteNewerThan } from '../common/util.jsx';\nimport log from './logger.js';\nimport * as storage from './storage.js';\nimport * as api from './api.js';\n// import { postToClients } from './serviceWorkerToClientApi.js';\nimport { encryptNotes, decryptNotes } from './crypto.js';\nimport _ from 'lodash';\n\nexport type SyncEvent =\n  | { type: 'syncStatus'; syncing: boolean }\n  | { type: 'unauthorized' }\n  | { type: 'error'; error: Error }\n  | { type: 'mergedNotes' };\nexport type SyncListener = (event: SyncEvent) => any;\n\nconst syncListeners: SyncListener[] = [];\nlet syncing = false;\nlet shouldSyncAgain = false;\nlet queueSyncRequired = false;\nlet interval: any;\n\nfunction callSyncListeners(event: SyncEvent) {\n  for (const listener of syncListeners) listener(event);\n}\n\nexport function addSyncEventListener(listener: SyncListener) {\n  syncListeners.push(listener);\n}\n\nexport function removeSyncEventListener(listener: SyncListener) {\n  const i = syncListeners.indexOf(listener);\n  if (i !== -1) syncListeners.splice(i, 1);\n}\n\nexport function syncInInterval() {\n  if (!interval) {\n    interval = setInterval(sync, 5000);\n    sync();\n  }\n}\n\nexport async function sync() {\n  // Prevent running until the last run is done.\n  if (syncing) {\n    log('sync deferred: already running.');\n    shouldSyncAgain = true;\n    return;\n  }\n\n  // Skip if user not logged in.\n  const user = await storage.getUser();\n  if (!user) return;\n\n  // Skip if this is a demo.\n  if (user.username === 'demo') return;\n\n  // NOTE: Don't do this. If the online status is not updated properly it'll get the sync stuck.\n  // Skip if user is offline\n  // if (!appStore.get().online) return;\n\n  log('sync started.');\n  shouldSyncAgain = false;\n  syncing = true;\n\n  // postToClients({ type: 'syncStatus', syncing: true });\n  callSyncListeners({ type: 'syncStatus', syncing: true });\n\n  let error: Error | undefined;\n  let mergeCount = 0;\n  const start = Date.now();\n  try {\n    // Delta sync. Server will either send delta sync data or request a queue sync.\n    if (!queueSyncRequired) {\n      const deltaSyncReq: t.DeltaSyncReq = await getDeltaSyncData(user);\n      const deltaSyncRes: t.DeltaSyncRes = await api.post('/api/delta-sync', deltaSyncReq);\n      // Server already checks if sync numbers are the same. But just to be sure we do it here too.\n      if (deltaSyncRes.type === 'ok' && deltaSyncReq.syncNumber === deltaSyncRes.syncNumber) {\n        mergeCount = await mergeSyncData(deltaSyncReq, deltaSyncRes, user);\n      } else {\n        queueSyncRequired = true;\n      }\n    }\n\n    // Queue sync.\n    if (queueSyncRequired) {\n      const queueSyncReq: t.QueueSyncReq = await getQueueSyncData();\n      const queueSyncRes: t.QueueSyncRes = await api.post('/api/queue-sync', queueSyncReq);\n      await mergeSyncHeadsData(queueSyncReq, queueSyncRes);\n      shouldSyncAgain = true;\n      queueSyncRequired = false;\n    }\n  } catch (err) {\n    // log.error(err);\n    error = err as Error;\n  }\n\n  log(`sync ended${error ? ' with error' : ''} in ${Date.now() - start}ms`);\n\n  syncing = false;\n\n  // Handle errors.\n  if (error) {\n    if (error instanceof TypeError) {\n      // TypeError is thrown when device is offline or server is down or there's a Cors problem etc.\n      // Should be ignored.\n    } else if (error instanceof ServerError && error.code === 401) {\n      // We cannot reset the cookie here because service worker doesn't have access to document\n      // and the Cookie Store API is not universally supported yet.\n      // setUserCookies('');\n      callSyncListeners({ type: 'unauthorized' });\n      // await storage.clearUser();\n      // postToClients({ type: 'refreshPage' });\n    } else if (error instanceof ServerError && error.type === 'app_requires_update') {\n      await self.registration.update();\n    } else {\n      callSyncListeners({ type: 'error', error });\n      // postToClients({ type: 'error', error: error.message });\n    }\n  }\n\n  // Tell clients about changes.\n  if (mergeCount > 0) {\n    // callSyncListeners({ type: 'notesInStorageChangedExternally' });\n    callSyncListeners({ type: 'mergedNotes' });\n  }\n\n  // Schedule another sync or tell client that sync is done.\n  if (shouldSyncAgain) {\n    setTimeout(sync, 0);\n  } else {\n    callSyncListeners({ type: 'syncStatus', syncing: false });\n  }\n}\n\nexport function isSyncing(): boolean {\n  return syncing;\n}\n\nasync function getDeltaSyncData(user: t.ClientLocalUser): Promise<t.SyncData> {\n  const res = await storage.transaction(\n    [storage.NOTES_STORE, storage.NOTES_QUEUE_STORE, storage.SETTINGS_STORE],\n    'readonly',\n    async tx => {\n      const items = await storage.waitForDBRequest(\n        tx.objectStore(storage.NOTES_QUEUE_STORE).getAll() as IDBRequest<t.NoteHead[]>,\n      );\n      const notesReqs = items.map(item => tx.objectStore(storage.NOTES_STORE).get(item.id) as IDBRequest<t.Note>);\n      const syncNumberReq = tx.objectStore(storage.SETTINGS_STORE).get('syncNumber') as IDBRequest<number | undefined>;\n      return { notesReqs, syncNumberReq };\n    },\n  );\n  const notes = await encryptNotes(\n    res.notesReqs.map(req => req.result),\n    user.encryptionKey,\n  );\n  return { notes, syncNumber: res.syncNumberReq.result ?? 0 };\n}\n\nasync function getQueueSyncData(): Promise<t.SyncHeadsData> {\n  const res = await storage.transaction([storage.NOTES_STORE, storage.SETTINGS_STORE], 'readonly', async tx => {\n    const notesReqs = tx.objectStore(storage.NOTES_STORE).getAll() as IDBRequest<t.Note[]>;\n    const syncNumberReq = tx.objectStore(storage.SETTINGS_STORE).get('syncNumber') as IDBRequest<number | undefined>;\n    return { notesReqs, syncNumberReq };\n  });\n  const noteHeads = res.notesReqs.result.map(note => ({ id: note.id, modification_date: note.modification_date }));\n  return { noteHeads, syncNumber: res.syncNumberReq.result ?? 0 };\n}\n\n// async function getSyncNumber(): Promise<number> {\n//   const res = await storage.transaction([storage.SETTINGS_STORE], 'readonly', async tx => {\n//     return tx.objectStore(storage.SETTINGS_STORE).get('syncNumber') as IDBRequest<number | undefined>;\n//   });\n//   return res.result ?? 0;\n// }\n\nasync function mergeSyncData(\n  reqSyncData: t.SyncData,\n  resSyncData: t.SyncData,\n  user: t.ClientLocalUser,\n): Promise<number> {\n  // Doing this one-by-one inside the transaction can cause the transaction to finish prematurely. I don't know why.\n  const receivedNotes = await decryptNotes(resSyncData.notes, user.encryptionKey);\n\n  return storage.transaction(\n    [storage.NOTES_STORE, storage.NOTES_QUEUE_STORE, storage.SETTINGS_STORE],\n    'readwrite',\n    async tx => {\n      let mergeCount = 0;\n      const notesStore = tx.objectStore(storage.NOTES_STORE);\n\n      // Replace local notes with received notes if necessary.\n      for (const receivedNote of receivedNotes) {\n        const localNote = await storage.waitForDBRequest(\n          notesStore.get(receivedNote.id) as IDBRequest<t.Note | undefined>,\n        );\n        if (isNoteNewerThan(receivedNote, localNote)) {\n          notesStore.put(receivedNote);\n          mergeCount++;\n        }\n      }\n\n      // Clear local note queue.\n      const queueStore = tx.objectStore(storage.NOTES_QUEUE_STORE);\n      const queuedNoteHeads = await storage.waitForDBRequest(queueStore.getAll() as IDBRequest<t.NoteHead[]>);\n      const sentNotesById = _.keyBy(reqSyncData.notes, 'id');\n      for (const queued of queuedNoteHeads) {\n        const sent = sentNotesById[queued.id] as t.EncryptedNote | undefined;\n        // Scenario 1.\n        // We send Note A.\n        // User modifies A -> A'.\n        // Then during merge, we see that A' (in queue) is newer than A (sent).\n        // We must NOT delete A'\n\n        // Scenario 2.\n        // We send Note A.\n        // Then during merge, we see that A' (in queue) is the same as A (sent).\n        // We MUST delete A'.\n\n        // Scenario 3.\n        // We send some notes but A doesn't exist.\n        // User creates A'\n        // During merge we have A' (in queue), but there's no A (nothing sent).\n        // We must NOT delete A'\n\n        // In other words, if the sent note is the same (or newer but that shoudn't happen) as the queued one, remove from queue.\n\n        if (sent && queued.modification_date <= sent.modification_date) {\n          queueStore.delete(queued.id);\n        }\n      }\n\n      // Update sync number.\n      const newSyncNumber = Math.max(reqSyncData.syncNumber, resSyncData.syncNumber) + 1;\n      tx.objectStore(storage.SETTINGS_STORE).put(newSyncNumber, 'syncNumber');\n\n      log('mergeSyncData mergeCount:', mergeCount);\n\n      return mergeCount;\n    },\n  );\n}\n\nasync function mergeSyncHeadsData(reqSyncHeadsData: t.SyncHeadsData, resSyncHeadsData: t.SyncHeadsData): Promise<void> {\n  return storage.transaction([storage.NOTES_QUEUE_STORE, storage.SETTINGS_STORE], 'readwrite', async tx => {\n    const queueStore = tx.objectStore(storage.NOTES_QUEUE_STORE);\n    const sentNoteHeads = reqSyncHeadsData.noteHeads;\n    const receivedNoteHeadsById = _.keyBy(resSyncHeadsData.noteHeads, 'id');\n    let addedToQueueCount = 0;\n    let removedFromQueueCount = 0;\n\n    const latestQueueItems = await storage.waitForDBRequest<t.NoteHead[]>(queueStore.getAll());\n    const latestQueueItemsById = _.keyBy(latestQueueItems, 'id');\n\n    // Put the sent note head in queue if necessary to be sent in full later, or delete it from queue.\n    for (const sentNoteHead of sentNoteHeads) {\n      const receivedNoteHead = receivedNoteHeadsById[sentNoteHead.id];\n      if (isNoteNewerThan(sentNoteHead, receivedNoteHead)) {\n        queueStore.put(sentNoteHead);\n        addedToQueueCount++;\n      } else if (latestQueueItemsById[sentNoteHead.id]) {\n        queueStore.delete(sentNoteHead.id);\n        removedFromQueueCount++;\n      }\n    }\n\n    // Update sync number.\n    const newSyncNumber = Math.max(reqSyncHeadsData.syncNumber, resSyncHeadsData.syncNumber) + 1;\n    tx.objectStore(storage.SETTINGS_STORE).put(newSyncNumber, 'syncNumber');\n\n    log(`mergeSyncHeadsData added ${addedToQueueCount} to queue and removed ${removedFromQueueCount}`);\n  });\n}\n\nexport const syncDebounced = _.debounce(sync, 1000, { leading: false, trailing: true, maxWait: 10 * 1000 });\n\nexport function requireQueueSync() {\n  queueSyncRequired = true;\n}\n"
  },
  {
    "path": "src/common/mdFns.ts",
    "content": "import _ from 'lodash';\n\n// space type space checkbox space content\nconst ulRegExp =\n  /^(?<space1>\\ *)((?<type>[\\*+-])(?<space2>\\ +)((?<checkbox>\\[[xX ]\\])(?<space3>\\ +))?)(?<content>.*)$/m;\nconst olRegExp =\n  /^(?<space1>\\ *)((?<type>\\d+[\\.\\)])(?<space2>\\ +)((?<checkbox>\\[[xX ]\\])(?<space3>\\ +))?)(?<content>.*)$/m;\nconst lineRegExp = /^(?<space1>\\ *)(?<content>.*)$/m;\nconst olNumberRegExp = /^(\\d+)([\\.\\)])$/;\n\nexport type Range = { start: number; end: number };\nexport type ListItem = {\n  space1: string;\n  type: string;\n  space2: string;\n  checkbox: string;\n  space3: string;\n  content: string;\n};\n\n// export function toggleIfOnCheckbox(text: string, i: number): string {\n//   const lineRange = getLineRangeAt(text, i);\n//   const line = getLine(text, lineRange);\n//   let listItem = parseListItem(line);\n//   if (listItem && isCursorOnCheckbox(listItem, i - lineRange.start)) {\n//     listItem = toggleListItemCheckbox(listItem);\n//     return insertText(text, stringifyListItem(listItem), lineRange);\n//   }\n\n//   return text;\n// }\n\n/**\n * i is relative to the start of listItem\n */\nexport function isCursorOnCheckbox(l: ListItem, i: number) {\n  const checkboxPos = l.space1.length + l.type.length + l.space2.length;\n  return l.checkbox && i >= checkboxPos && i < checkboxPos + 4;\n}\n\nexport function toggleListItemCheckbox(l: ListItem): ListItem {\n  return setListItemCheckbox(l, l.checkbox === '[ ]');\n}\n\nexport function setListItemCheckbox(l: ListItem, checked: boolean): ListItem {\n  return { ...l, checkbox: checked ? '[x]' : '[ ]' };\n}\n\nexport function removeListItemCheckbox(l: ListItem): ListItem {\n  // '- [ ] task' -> '- task'\n  return { space1: l.space1, type: l.type, space2: ' ', checkbox: '', space3: '', content: l.content };\n}\n\nexport function addListItemCheckbox(l: ListItem): ListItem {\n  // 'task' -> '- [ ]  task'\n  // '-  task' -> '- [ ]  task'\n  return { space1: l.space1, type: l.type || '-', space2: ' ', checkbox: '[ ]', space3: ' ', content: l.content };\n}\n\nexport function removeListItemType(l: ListItem): ListItem {\n  // '-  task' -> 'task'\n  // '-  [ ] task' -> 'task'\n  return { space1: l.space1, type: '', space2: '', checkbox: '', space3: '', content: l.content };\n}\n\nexport function incrementListItemNumber(l: ListItem): ListItem {\n  // '1.  task' -> '2. task'\n  // '1)  [ ] task' -> '2) [ ] task'\n  const match = l.type.match(olNumberRegExp);\n  if (!match) return l;\n  const type = String(Number(match[1]) + 1) + match[2];\n  return { space1: l.space1, type, space2: l.space2, checkbox: l.checkbox, space3: l.space3, content: l.content };\n}\n\nexport function parseListItem(line: string): ListItem {\n  let match = line.match(ulRegExp) ?? line.match(olRegExp) ?? line.match(lineRegExp);\n  const { space1 = '', type = '', space2 = '', checkbox = '', space3 = '', content = '' } = match!.groups as any;\n  return { space1, type, space2, checkbox, space3, content };\n}\n\n/**\n * Resulting range is relative to the given list item.\n */\nexport function getListItemCheckboxRange(l: ListItem): Range {\n  const start = l.space1.length + l.type.length + l.space2.length;\n  return { start, end: start + l.checkbox.length };\n}\n\nexport function stringifyListItem(l: ListItem): string {\n  return stringifyListItemPrefix(l) + l.content;\n}\n\nexport function stringifyListItemPrefix(l: ListItem): string {\n  return l.space1 + l.type + l.space2 + l.checkbox + l.space3;\n}\n\nexport function insertText(text: string, segment: string, range: Range): string {\n  return text.substring(0, range.start) + segment + text.substring(range.end);\n}\n\nexport function getLineRangeAt(text: string, i: number): Range {\n  return { start: getLineStart(text, i), end: getLineEnd(text, i) };\n}\n\nexport function getLine(text: string, range: Range): string {\n  return text.substring(range.start, range.end);\n}\n\nfunction getLineStart(text: string, i: number): number {\n  while (i > 0 && text[i - 1] !== '\\n') i--;\n  return i;\n}\n\nfunction getLineEnd(text: string, i: number): number {\n  while (i < text.length && text[i] !== '\\r' && text[i] !== '\\n') i++;\n  return i;\n}\n\nexport function skipWhitespaceSameLine(text: string, i: number): number {\n  while ((i < text.length && text[i] === ' ') || text[i] === '\\t') i++;\n  return i;\n}\n"
  },
  {
    "path": "src/common/types.ts",
    "content": "import type { Draft } from 'immer';\n\nexport type Note = {\n  // UUID version 4\n  id: string;\n\n  // Deleted notes have null text\n  text: string | null;\n\n  // ISO 8601 format\n  creation_date: string;\n\n  // ISO 8601 format\n  modification_date: string;\n\n  // 0 means deleted, 1 means not deleted\n  not_deleted: number;\n\n  // 0 means archived, 1 means not archived\n  not_archived: number;\n\n  // 0 means not pinned, 1 means pinned\n  pinned: number;\n\n  // A higher number means higher on the list\n  // Usually, by default it's milliseconds since the epoch\n  order: number;\n};\n\nexport type EncryptedNote = EncryptedData & {\n  id: string;\n  modification_date: string;\n};\n\nexport type EncryptedData = {\n  // The encrypted Note in base64 format\n  encrypted_base64: string;\n\n  // Initial vector, a random number, that was used for encrypting this specific note\n  iv: string;\n};\n\nexport type DBEncryptedNote = EncryptedNote & {\n  username: string;\n};\n\nexport type DBUser = {\n  username: string;\n  password_double_hash: string;\n  password_salt: string;\n  encryption_salt: string;\n};\n\nexport type DBClient = {\n  username: string;\n  token: string;\n  sync_number: number;\n  last_activity_date: string;\n};\n\nexport type SignupData = {\n  username: string;\n  password_client_hash: string;\n  encryption_salt: string;\n};\n\nexport type LoginData = {\n  username: string;\n  password_client_hash: string;\n};\n\nexport type LoginResponse = {\n  username: string;\n  token: string;\n  encryption_salt: string;\n};\n\nexport type UsernamePassword = {\n  username: string;\n  password: string;\n};\n\nexport type SyncData = {\n  notes: EncryptedNote[];\n  syncNumber: number;\n};\n\nexport type SyncHeadsData = {\n  noteHeads: NoteHead[];\n  syncNumber: number;\n};\n\nexport type NoteHead = {\n  id: string;\n  modification_date: string;\n};\n\nexport type DBNoteHead = NoteHead & {\n  token: string;\n};\n\nexport type DeltaSyncReq = SyncData;\n\nexport type DeltaSyncResNormal = {\n  type: 'ok';\n} & SyncData;\n\nexport type DeltaSyncResRequireQueueSync = {\n  type: 'require_queue_sync';\n};\n\nexport type DeltaSyncRes = DeltaSyncResNormal | DeltaSyncResRequireQueueSync;\n\nexport type QueueSyncReq = SyncHeadsData;\n\nexport type QueueSyncRes = SyncHeadsData;\n\nexport type ServerConfig = {\n  port: number;\n};\n\nexport type ServerUserClient = {\n  username: string;\n  token: string;\n};\n\nexport type ClientLocalUser = {\n  username: string;\n  token: string;\n  encryptionKey: CryptoKey;\n};\n\nexport type AppStore = {\n  hidePinnedNotes: boolean;\n  showArchive: boolean;\n  notes: Note[];\n  search?: string;\n  noteSelection?: string[];\n  // notesUpdateRequestTimestamp: number;\n  // notesUpdateTimestamp: number;\n  notePages: number;\n  notePageSize: number;\n  allNotePagesLoaded: boolean;\n  user?: ClientLocalUser;\n  message?: { text: string; type: 'info' | 'error'; timestamp: number };\n  syncing: boolean;\n  updatingNotes: boolean;\n  queueCount: number;\n  online: boolean;\n  requirePageRefresh: boolean;\n};\n\nexport type AppStoreRecipe = (draft: Draft<AppStore>) => AppStore | void;\nexport type AppStoreListener = (newStore: AppStore, oldStore: AppStore) => void;\n\nexport type ParsedLine = {\n  wholeLine: string;\n  padding: number;\n  bullet: string;\n  checkbox: boolean;\n  checked: boolean;\n  start: number;\n  end: number;\n  bodyText: string;\n  bodyStart: number;\n  contentStart: number;\n  lastLine: boolean;\n};\n\nexport type ServerErrorJSON = {\n  message: string;\n  code: number;\n  type: ServerErrorType;\n};\n\nexport type ServerErrorType = 'app_requires_update' | 'generic';\n\nexport type HistoryState = {\n  // fromNotesPage?: boolean;\n};\n\nexport type ClientToServiceWorkerMessage = void;\n// | { command: 'update' }\n// | { command: 'sync'; queue?: boolean; debounced?: boolean }\n// | { command: 'tellOthersToRefreshPage' }\n// | { command: 'tellOthersNotesInStorageChanged' }\n// | { command: 'sendSyncStatus' }\n// | { command: 'newClient' };\n\nexport type ServiceWorkerToClientMessage =\n  | { command: 'serviceWorkerActivated'; cacheVersion: number }\n  // | { command: 'syncStatus'; syncing: boolean }\n  // | { command: 'notesInStorageChangedExternally' }\n  // | { command: 'refreshPage' }\n  | { command: 'error'; error: string };\n// | { command: 'resetUser' };\n\nexport type BroadcastChannelMessage =\n  | { type: 'notesInStorageChanged'; unforgetContextId: string }\n  | { type: 'refreshPage'; unforgetContextId: string };\n"
  },
  {
    "path": "src/common/util.ts",
    "content": "import type * as t from './types.js';\nimport { v4 as uuid } from 'uuid';\n\nexport const CACHE_VERSION = 188;\n\nexport function assert(condition: any, message: string): asserts condition {\n  if (!condition) throw new Error(message);\n}\n\nexport function isNoteNewerThan(a: t.NoteHead, b?: t.NoteHead): boolean {\n  assert(!b || a.id === b.id, 'Cannot compare notes with different IDs');\n  return !b || a.modification_date > b.modification_date;\n}\n\nexport function escapeRegExp(str: string): string {\n  // Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'); // $& means the whole matched string\n}\n\n// export function parseLine(text: string, cur: number): t.ParsedLine {\n//   const isLookingAt = (sub: string) => text.startsWith(sub, cur);\n//   const start = findBeginningOfLine(text, cur);\n//   const end = findEndOfLine(text, cur);\n//   const lastLine = !text[end];\n\n//   cur = skipWhitespaceSameLine(text, start);\n//   const padding = cur - start;\n//   const contentStart = cur;\n\n//   let bullet = '';\n//   if (isLookingAt('- ') || isLookingAt('+ ') || isLookingAt('* ')) {\n//     bullet = text[cur];\n//     cur += 2;\n//   }\n\n//   const checked = Boolean(bullet) && (isLookingAt('[x] ') || isLookingAt('[X] '));\n//   const checkbox = Boolean(bullet) && (checked || isLookingAt('[ ] '));\n//   if (checkbox) cur += 4;\n\n//   const bodyText = text.substring(cur, end);\n//   const bodyStart = cur;\n//   const wholeLine = text.substring(start, end);\n\n//   return { wholeLine, padding, bullet, checkbox, checked, start, end, bodyText, bodyStart, contentStart, lastLine };\n// }\n\n// export function parseLines(text: string): t.ParsedLine[] {\n//   const lines: t.ParsedLine[] = [];\n//   let cur = 0;\n//   while (cur < text.length) {\n//     const line = parseLine(text, cur);\n//     lines.push(line);\n//     cur = line.end + 1;\n//   }\n//   return lines;\n// }\n\n// function isLookingAtCheckbox(text: string, cur: number): boolean {\n//   return isLookingAtBullet(text, cur)\n//   return text.startsWith('- [ ] ', cur) || text.startsWith('- [x] ', cur) || text.startsWith(' - [X] ', cur);\n// }\n\n// function isLookingAtBullet(text: string, cur: number): boolean {\n//   return text.startsWith('- ', cur) || text.startsWith('+ ', cur);\n// }\n\n// function getCheckboxBody(text: string, startOfCheckbox: number): string {\n//   if (!isLookingAtCheckbox(text, startOfCheckbox)) throw new Error('expected start of checkbox');\n//   return text.substring(startOfCheckbox + '- [ ] '.length, findEndOfLine(text, startOfCheckbox));\n// }\n\n// export function skipWhitespaceSameLine(text: string, cur: number): number {\n//   while ((cur < text.length && text[cur] === ' ') || text[cur] === '\\t') cur++;\n//   return cur;\n// }\n\n// export function insertText(text: string, segment: string, start: number, end?: number): string {\n//   return text.substring(0, start) + segment + text.substring(end ?? start);\n// }\n\n// export function toggleLineCheckbox(line: t.ParsedLine): string {\n//   return setLineCheckbox(line, !line.checked);\n// }\n\n// export function setLineCheckbox(line: t.ParsedLine, checked: boolean): string {\n//   return ' '.repeat(line.padding) + line.bullet + ' ' + (checked ? '[x] ' : '[ ] ') + line.bodyText;\n// }\n\nexport function calcNewSelection(\n  origSelection: number,\n  deleteStart: number,\n  deleteEnd: number,\n  insertLength: number,\n): number {\n  if (origSelection < deleteStart) return origSelection;\n\n  origSelection = Math.max(origSelection, deleteEnd);\n  const deleteLength = deleteEnd - deleteStart;\n  const added = insertLength - deleteLength;\n  return origSelection + added;\n}\n\nexport function bytesToHexString(bytes: Uint8Array<ArrayBuffer>): string {\n  return Array.from(bytes)\n    .map(byte => byte.toString(16).padStart(2, '0'))\n    .join('');\n}\n\nexport function hexStringToBytes(str: string): BufferSource {\n  if (str.length % 2) throw new Error('hexStringToBytes invalid string');\n  const buffer = new ArrayBuffer(str.length / 2);\n  const bytes = new Uint8Array(buffer);\n  for (let i = 0; i < str.length; i += 2) {\n    bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);\n  }\n  return bytes;\n}\n\nexport class ServerError extends Error {\n  constructor(\n    message: string,\n    public code: number,\n    public type: t.ServerErrorType = 'generic',\n  ) {\n    super(message);\n  }\n\n  static fromJSON(json: any): ServerError {\n    return new ServerError(json.message, json.code, json.type);\n  }\n\n  toJSON() {\n    return { message: this.message, code: this.code, type: this.type };\n  }\n}\n\nexport function createNewNote(text: string): t.Note {\n  const now = Date.now();\n  return {\n    id: uuid(),\n    text,\n    creation_date: new Date(now).toISOString(),\n    modification_date: new Date(now).toISOString(),\n    order: now,\n    not_deleted: 1,\n    not_archived: 1,\n    pinned: 0,\n  };\n}\n\n// Custom format: Friday 2 Jun 2024 at 10:30\nexport function formatDateTime(date: Date) {\n  // const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thur', 'Fri', 'Sat'];\n  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n\n  // const dayName = days[date.getDay()];\n  const day = date.getDate();\n  const monthName = months[date.getMonth()];\n  const year = date.getFullYear();\n  const hours = String(date.getHours()).padStart(2, '0');\n  const minutes = String(date.getMinutes()).padStart(2, '0');\n\n  return `${day} ${monthName} ${year} - ${hours}:${minutes}`;\n}\n"
  },
  {
    "path": "src/server/db.ts",
    "content": "import type * as t from '../common/types.js';\nimport * as cutil from '../common/util.js';\nimport Database, { Statement } from 'better-sqlite3';\nimport path from 'node:path';\nimport _ from 'lodash';\n\nlet db: Database.Database;\n\nexport function initDB() {\n  const dbPath = path.join('private/unforget.db');\n  const dbLog = (..._args: any[]) => {\n    // if (process.env.NODE_ENV === 'development') {\n    //   console.log('sqlite: ', ...args);\n    // }\n  };\n\n  db = new Database(dbPath, { verbose: dbLog });\n  db.pragma('journal_mode = WAL');\n\n  db.prepare(\n    `\n    CREATE TABLE IF NOT EXISTS notes (\n      id                    TEXT PRIMARY KEY,\n      username              TEXT NOT NULL,\n      modification_date     TEXT NOT NULL,\n      iv                    TEXT NOT NULL,\n      encrypted_base64      TEXT\n    )`,\n  ).run();\n  db.prepare(`CREATE INDEX IF NOT EXISTS index_notes_username on notes (username)`).run();\n  db.prepare(`CREATE INDEX IF NOT EXISTS index_notes_modification_date on notes (modification_date)`).run();\n\n  db.prepare(\n    `\n    CREATE TABLE IF NOT EXISTS users (\n      username              TEXT PRIMARY KEY,\n      password_double_hash  TEXT NOT NULL,\n      password_salt         TEXT NOT NULL,\n      encryption_salt       TEXT NOT NULL\n    )`,\n  ).run();\n\n  db.prepare(\n    `\n    CREATE TABLE IF NOT EXISTS clients (\n      token                 TEXT PRIMARY KEY,\n      username              TEXT NOT NULL,\n      sync_number           INTEGER NOT NULL,\n      last_activity_date    TEXT NOT NULL\n    )`,\n  ).run();\n  db.prepare(`CREATE INDEX IF NOT EXISTS index_clients_username on clients (username)`).run();\n  db.prepare(`CREATE INDEX IF NOT EXISTS index_clients_last_activity_date on clients (last_activity_date)`).run();\n\n  db.prepare(\n    `\n    CREATE TABLE IF NOT EXISTS notes_queue (\n      token                 TEXT NOT NULL,\n      id                    TEXT NOT NULL,\n      modification_date     TEXT NOT NULL,\n      PRIMARY KEY (token, id)\n    )`,\n  ).run();\n}\n\nexport function get(): Database.Database {\n  return db;\n}\n\nexport function getSyncNumber(client: t.ServerUserClient) {\n  return db.prepare(`SELECT sync_number FROM clients where token = ?`).pluck().get(client.token) as number;\n}\n\nexport function getQueuedNotes(client: t.ServerUserClient): t.EncryptedNote[] {\n  const dbNotes = db\n    .prepare(`SELECT * FROM notes WHERE id IN (SELECT id FROM notes_queue WHERE token = ?)`)\n    .all(client.token) as t.DBEncryptedNote[];\n  return dbNotes.map(dbNoteToNote);\n}\n\nexport function getQueuedNoteHeads(client: t.ServerUserClient): t.NoteHead[] {\n  return db.prepare(`SELECT id, modification_date FROM notes_queue WHERE token = ?`).all(client.token) as t.NoteHead[];\n}\n\nexport function getNotes(client: t.ServerUserClient, ids?: string[]): t.EncryptedNote[] {\n  let dbNotes: t.DBEncryptedNote[];\n  if (ids && ids?.length > 0) {\n    const placeholders = _.map(ids, () => '?').join(',');\n    dbNotes = db\n      .prepare(`SELECT * FROM notes WHERE username = ? AND id IN (${placeholders})`)\n      .all(client.username, ...ids) as any;\n  } else {\n    dbNotes = db.prepare(`SELECT * FROM notes WHERE username = ?`).all(client.username) as any;\n  }\n  return dbNotes.map(dbNoteToNote);\n}\n\nexport function getNoteHeads(client: t.ServerUserClient): t.NoteHead[] {\n  return db.prepare(`SELECT id, modification_date FROM notes WHERE username = ?`).all(client.username) as t.NoteHead[];\n}\n\nexport function dbNoteToNote(dbNote: t.DBEncryptedNote): t.EncryptedNote {\n  return _.omit(dbNote, 'username');\n}\n\nexport function logout(token: string) {\n  const deleteFromQueue = db.prepare<[string]>(`DELETE FROM notes_queue WHERE token = ?`);\n  const deleteFromClient = db.prepare<[string]>(`DELETE FROM clients WHERE token = ?`);\n\n  db.transaction(() => {\n    deleteFromQueue.run(token);\n    deleteFromClient.run(token);\n  })();\n}\n\nexport function mergeSyncData(\n  client: t.ServerUserClient,\n  reqSyncData: t.SyncData,\n  resSyncData: t.SyncData,\n  shouldUpdateSyncNumber: boolean,\n) {\n  const getDbNote = db.prepare<[{ username: string; id: string }]>(\n    `SELECT * FROM notes WHERE username = :username AND id = :id`,\n  );\n  const putDbNote = preparePutNote();\n  const deleteFromQueue = db.prepare<[{ token: string; id: string }]>(\n    `DELETE FROM notes_queue WHERE token = :token AND id = :id`,\n  );\n  const updateSyncNumber = db.prepare<[{ token: string; sync_number: number }]>(\n    `UPDATE clients SET sync_number = :sync_number WHERE token = :token`,\n  );\n  const getClients = db.prepare<[t.ServerUserClient]>(\n    `SELECT username, token FROM clients WHERE username = :username AND token != :token`,\n  );\n  const insertIntoQueue = prepareInsertIntoQueue();\n\n  db.transaction(() => {\n    // Replace local notes with received notes if necessary.\n    for (const receivedNote of reqSyncData.notes) {\n      const localNote = getDbNote.get({ username: client.username, id: receivedNote.id }) as\n        | t.DBEncryptedNote\n        | undefined;\n\n      if (cutil.isNoteNewerThan(receivedNote, localNote)) {\n        const dbNote: t.DBEncryptedNote = { ...receivedNote, username: client.username };\n        putDbNote.run(dbNote);\n      }\n    }\n\n    // Clear local note queue.\n    const queuedNoteHeads = getQueuedNoteHeads(client);\n    const sentNotesById = _.keyBy(resSyncData.notes, 'id');\n    for (const queued of queuedNoteHeads) {\n      const sent = sentNotesById[queued.id] as t.EncryptedNote | undefined;\n      if (sent && queued.modification_date <= sent.modification_date) {\n        deleteFromQueue.run({ token: client.token, id: queued.id });\n      }\n    }\n\n    // Add received notes to notes_queue for other clients of the same user.\n    const otherClients = getClients.all(client) as t.ServerUserClient[];\n    for (const receivedNote of reqSyncData.notes) {\n      for (const otherClient of otherClients) {\n        const dbNoteHead: t.DBNoteHead = {\n          id: receivedNote.id,\n          modification_date: receivedNote.modification_date,\n          token: otherClient.token,\n        };\n        insertIntoQueue.run(dbNoteHead);\n      }\n    }\n\n    // Update sync number.\n    if (shouldUpdateSyncNumber) {\n      const newSyncNumber = Math.max(reqSyncData.syncNumber, resSyncData.syncNumber) + 1;\n      updateSyncNumber.run({ token: client.token, sync_number: newSyncNumber });\n    }\n  })();\n}\n\nexport function mergeSyncHeadsData(\n  client: t.ServerUserClient,\n  reqSyncHeadsData: t.SyncHeadsData,\n  resSyncHeadsData: t.SyncHeadsData,\n) {\n  const deleteFromQueue = db.prepare<[{ token: string; id: string }]>(\n    `DELETE FROM notes_queue WHERE token = :token AND id = :id`,\n  );\n  const updateSyncNumber = db.prepare<[{ token: string; sync_number: number }]>(\n    `UPDATE clients SET sync_number = :sync_number WHERE token = :token`,\n  );\n  const insertIntoQueue = prepareInsertIntoQueue();\n\n  db.transaction(() => {\n    const sentNoteHeads = resSyncHeadsData.noteHeads;\n    const receivedNoteHeadsById = _.keyBy(reqSyncHeadsData.noteHeads, 'id');\n    let addedToQueueCount = 0;\n    let removedFromQueueCount = 0;\n\n    const latestQueueItems = getQueuedNoteHeads(client);\n    const latestQueueItemsById = _.keyBy(latestQueueItems, 'id');\n\n    // Put the sent note head in queue if necessary to be sent in full later, or delete it from queue.\n    for (const sentNoteHead of sentNoteHeads) {\n      const receivedNoteHead = receivedNoteHeadsById[sentNoteHead.id];\n      if (cutil.isNoteNewerThan(sentNoteHead, receivedNoteHead)) {\n        insertIntoQueue.run({\n          id: sentNoteHead.id,\n          modification_date: sentNoteHead.modification_date,\n          token: client.token,\n        });\n        addedToQueueCount++;\n      } else if (latestQueueItemsById[sentNoteHead.id]) {\n        deleteFromQueue.run({ token: client.token, id: sentNoteHead.id });\n        removedFromQueueCount++;\n      }\n    }\n\n    // Update sync number.\n    const newSyncNumber = Math.max(reqSyncHeadsData.syncNumber, resSyncHeadsData.syncNumber) + 1;\n    updateSyncNumber.run({ token: client.token, sync_number: newSyncNumber });\n\n    if (process.env.NODE_ENV === 'development') {\n      console.log(`mergeSyncHeadsData added ${addedToQueueCount} to queue and removed ${removedFromQueueCount}`);\n    }\n  })();\n}\n\nexport function importNotes(username: string, notes: t.EncryptedNote[]) {\n  const getDbNote = db.prepare<[{ username: string; id: string }]>(\n    `SELECT * FROM notes WHERE username = :username AND id = :id`,\n  );\n  const putDbNote = preparePutNote();\n  const getClients = db.prepare<[string]>(`SELECT username, token FROM clients WHERE username = ?`);\n  const insertIntoQueue = prepareInsertIntoQueue();\n\n  db.transaction(() => {\n    // Replace local notes with notes if necessary.\n    for (const note of notes) {\n      const localNote = getDbNote.get({ username, id: note.id }) as t.DBEncryptedNote | undefined;\n      if (cutil.isNoteNewerThan(note, localNote)) {\n        const dbNote: t.DBEncryptedNote = { ...note, username };\n        console.log(dbNote);\n        putDbNote.run(dbNote);\n      }\n    }\n\n    // Add notes to notes_queue for all clients of the user.\n    const clients = getClients.all(username) as t.ServerUserClient[];\n    for (const note of notes) {\n      for (const client of clients) {\n        const dbNoteHead: t.DBNoteHead = {\n          id: note.id,\n          modification_date: note.modification_date,\n          token: client.token,\n        };\n        insertIntoQueue.run(dbNoteHead);\n      }\n    }\n  })();\n}\n\nfunction prepareInsertIntoQueue(): Statement<[t.DBNoteHead]> {\n  return db.prepare(`\n    INSERT INTO notes_queue (token, id, modification_date)\n    VALUES (:token, :id, :modification_date)\n    ON CONFLICT (token, id) DO UPDATE SET\n      modification_date = excluded.modification_date\n    WHERE excluded.modification_date > notes_queue.modification_date\n  `);\n}\n\nfunction preparePutNote(): Statement<[t.DBEncryptedNote]> {\n  return db.prepare(`\n    INSERT OR REPLACE INTO notes\n      (id, username, modification_date, encrypted_base64, iv)\n    VALUES\n      (:id, :username, :modification_date, :encrypted_base64, :iv)\n  `);\n}\n"
  },
  {
    "path": "src/server/index.ts",
    "content": "import 'dotenv/config';\nimport './validateEnvVars.js';\n\nimport express from 'express';\nimport path from 'node:path';\nimport crypto from 'node:crypto';\nimport type * as t from '../common/types.js';\nimport * as db from './db.js';\nimport { ServerError, bytesToHexString } from '../common/util.js';\nimport cookieParser from 'cookie-parser';\nimport _ from 'lodash';\n\nconst PUBLIC = path.join(process.cwd(), 'public');\nconst DIST_PUBLIC = path.join(process.cwd(), 'dist/public');\n\n// const icons = _.filter(fs.readdirSync(path.join(PUBLIC, 'icons')), name => name.endsWith('.svg'));\n\ndeclare global {\n  namespace Express {\n    interface Locals {\n      client?: t.ServerUserClient;\n    }\n  }\n}\n\n// declare module 'express' {\n//   export interface Response {\n//     locals: MyLocals;\n//   }\n// }\n\ndb.initDB();\n\nconst app = express();\napp.use(express.json({ limit: '50MB' }));\n\napp.use('/', express.static(PUBLIC));\napp.use('/', express.static(DIST_PUBLIC));\n\napp.use(cookieParser());\n\napp.use((req, res, next) => {\n  const token = (req.query['token'] || req.cookies.unforget_token) as string | undefined;\n  let client: t.ServerUserClient | undefined;\n  if (token) {\n    client = db.get().prepare(`SELECT username, token FROM clients WHERE token = ?`).get(token) as\n      | t.ServerUserClient\n      | undefined;\n    if (client) {\n      db.get()\n        .prepare(\n          `UPDATE clients SET last_activity_date = :last_activity_date WHERE username = :username AND token = :token`,\n        )\n        .run({ ...client, last_activity_date: new Date().toISOString() });\n    }\n  }\n  res.locals = { client };\n  log(\n    res,\n    `${req.method} ${req.path} X-Service-Worker-Cache-Version: ${\n      req.header('X-Service-Worker-Cache-Version') || 'unknown'\n    }, X-Client-Cache-Version: ${req.header('X-Client-Cache-Version') || 'unknown'}`,\n  );\n\n  next();\n});\n\n// app.use('/api', (req, res, next) => {\n//   if (req.query.apiProtocol === '2') {\n//     next();\n//   } else {\n//     next(new ServerError('App requires update', 400, 'app_requires_update'));\n//   }\n// });\n\napp.post('/api/login', async (req, res, next) => {\n  try {\n    let loginData = req.body as t.LoginData;\n    logDebug(res, '/api/login', req.body);\n    loginData = { ...loginData, username: loginData.username.toLowerCase() };\n    const user = db.get().prepare(`SELECT * FROM users WHERE username = ?`).get(loginData.username) as\n      | t.DBUser\n      | undefined;\n\n    if (user) {\n      const password_double_hash = await calcDoublePasswordHash(loginData.password_client_hash, user.password_salt);\n      if (password_double_hash === user.password_double_hash) {\n        loginAndRespond(user, res);\n        return;\n      }\n    }\n\n    throw new ServerError('Wrong username or password.', 401);\n  } catch (error) {\n    next(error);\n  }\n});\n\napp.post('/api/signup', async (req, res, next) => {\n  try {\n    let signupData = req.body as t.SignupData;\n    signupData = { ...signupData, username: signupData.username.toLowerCase() };\n    if (typeof signupData.username !== 'string' || signupData.username.length < 3) {\n      throw new ServerError('username must be at least 3 characters', 400);\n    }\n    if (/[\\/\\\\<>&'\"]/.test(signupData.username)) {\n      throw new ServerError('invalid characters in username', 400);\n    }\n\n    if (signupData.username === 'demo') throw new ServerError('Username already exists.', 400);\n\n    const user = db.get().prepare(`SELECT * FROM users WHERE username = ?`).get(signupData.username) as\n      | t.DBUser\n      | undefined;\n    if (user) throw new ServerError('Username already exists.', 400);\n\n    const password_salt = generateRandomCryptoString();\n    const password_double_hash = await calcDoublePasswordHash(signupData.password_client_hash, password_salt);\n    const newUser: t.DBUser = {\n      username: signupData.username,\n      password_double_hash,\n      password_salt,\n      encryption_salt: signupData.encryption_salt,\n    };\n    db.get()\n      .prepare(\n        `\n        INSERT INTO users (username, password_double_hash, password_salt, encryption_salt)\n        VALUES (:username, :password_double_hash, :password_salt, :encryption_salt)`,\n      )\n      .run(newUser);\n    loginAndRespond(newUser, res);\n  } catch (error) {\n    next(error);\n  }\n});\n\nfunction loginAndRespond(user: t.DBUser, res: express.Response) {\n  const token = generateRandomCryptoString();\n  const dbClient: t.DBClient = {\n    username: user.username,\n    token,\n    sync_number: 0,\n    last_activity_date: new Date().toISOString(),\n  };\n  db.get()\n    .prepare(\n      `\n      INSERT INTO clients (username, token, sync_number, last_activity_date)\n      VALUES (:username, :token, :sync_number, :last_activity_date)\n      `,\n    )\n    .run(dbClient);\n  const maxAge = 10 * 365 * 24 * 3600 * 1000; // 10 years in milliseconds\n  res.cookie('unforget_token', token, { maxAge, path: '/' });\n  // res.cookie('unforget_username', user.username, { maxAge, path: '/' });\n  const loginResponse: t.LoginResponse = { username: user.username, token, encryption_salt: user.encryption_salt };\n  res.send(loginResponse);\n}\n\napp.post('/api/error', (req, res) => {\n  const { message } = req.body as { message: string };\n  logError(res, 'client error: ' + message);\n  res.send({ ok: true });\n});\n\napp.post('/api/log', (req, res) => {\n  const { message } = req.body as { message: string };\n  log(res, 'client log: ' + message);\n  res.send({ ok: true });\n});\n\napp.post('/api/partial-sync', (_req, _res, next) => {\n  next(new ServerError('App requires update', 400, 'app_requires_update'));\n});\n\napp.post('/api/delta-sync', authenticate, (req, res) => {\n  logDebug(res, req.body);\n  const client = res.locals.client!;\n  const deltaSyncReq: t.DeltaSyncReq = req.body;\n  const syncNumber = db.getSyncNumber(client);\n\n  // Require queue sync if the sync numbers differ.\n  if (syncNumber !== deltaSyncReq.syncNumber) {\n    const queueSyncRes: t.DeltaSyncRes = { type: 'require_queue_sync' };\n    res.send(queueSyncRes);\n    return;\n  }\n\n  // When the sync number is 0, send all the notes, otherwise only the queued notes.\n  const notes = syncNumber === 0 ? db.getNotes(client) : db.getQueuedNotes(client);\n  const deltaSyncRes: t.DeltaSyncRes = { type: 'ok', notes, syncNumber };\n\n  db.mergeSyncData(client, deltaSyncReq, deltaSyncRes, true);\n  res.send(deltaSyncRes);\n});\n\napp.post('/api/full-sync', (_req, _res, next) => {\n  next(new ServerError('App requires update', 400, 'app_requires_update'));\n});\n\napp.post('/api/queue-sync', authenticate, (req, res, _next) => {\n  const client = res.locals.client!;\n  const queueSyncReq: t.QueueSyncReq = req.body;\n  const syncNumber = db.getSyncNumber(client);\n\n  logDebug(res, 'sync number from db: ', syncNumber);\n\n  const queueSyncRes: t.QueueSyncRes = { noteHeads: db.getNoteHeads(client), syncNumber };\n\n  db.mergeSyncHeadsData(client, queueSyncReq, queueSyncRes);\n  res.send(queueSyncRes);\n});\n\napp.post('/api/get-notes', authenticate, (req, res) => {\n  let ids: string[] | undefined;\n  if (req.body?.ids) ids = req.body.ids;\n\n  const client = res.locals.client!;\n  const notes = db.getNotes(client, ids);\n  res.set('Cache-Control', 'no-cache').send(notes);\n});\n\napp.post('/api/merge-notes', authenticate, (req, res, _next) => {\n  logDebug(res, req.body);\n  const client = res.locals.client!;\n  const { notes } = req.body as { notes: t.EncryptedNote[] };\n\n  const syncNumber = 0;\n  const deltaSyncReq: t.DeltaSyncReq = { notes, syncNumber };\n  const deltaSyncRes: t.DeltaSyncRes = { type: 'ok', notes: [], syncNumber };\n\n  db.mergeSyncData(client, deltaSyncReq, deltaSyncRes, false);\n  res.send({ ok: true });\n});\n\napp.post('/api/logout', (_req, res, next) => {\n  const token = res.locals.client?.token;\n  if (!token) return next(new Error('Missing token'));\n  db.logout(token);\n  res.send({ ok: true });\n});\n\napp.get(['/', '/import', '/export', '/about', '/archive', '/n/:noteId', '/login', '/demo'], (_req, res) => {\n  // const preload = _.map(icons, icon => `<link rel=\"preload\" href=\"/icons/${icon}\" as=\"image\">`).join('\\n');\n  // const preload = '';\n  res.send(`<!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, user-scalable=no\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n    <title>Unforget</title>\n    <link rel=\"stylesheet\" href=\"/style.css\">\n    <link rel=\"manifest\" href=\"/manifest.json\" />\n    <link rel=\"icon\" href=\"/icon-256x256.png\" type=\"image/png\" />\n\t  <script src=\"/index.js\"></script>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n  </body>\n</html>`);\n});\n\napp.use((req, res, next) => {\n  logError(res, `Page not found: ${req.url}`);\n  next(new ServerError(`Page not found: ${req.url}`, 404));\n});\n\napp.use(((error, _req, res, _next) => {\n  if (!(error instanceof ServerError)) {\n    error = new ServerError(error.message, 500, 'generic');\n  }\n  logError(res, error);\n  res.status(error.code).send(error.toJSON());\n}) as express.ErrorRequestHandler);\n\napp.listen(Number(process.env.PORT), () => {\n  console.log(`Listening on port ${process.env.PORT}`);\n});\n\nasync function calcDoublePasswordHash(password_client_hash: string, password_salt: string) {\n  const salted = password_salt + password_client_hash;\n  return computeSHA256(new TextEncoder().encode(salted));\n}\n\nasync function computeSHA256(data: Uint8Array): Promise<string> {\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n  return bytesToHexString(new Uint8Array(hashBuffer));\n}\n\nfunction authenticate(_req: express.Request, res: express.Response, next: express.NextFunction) {\n  if (!res.locals.client) {\n    next(new ServerError('Unauthorized', 401));\n  } else {\n    next();\n  }\n}\n\nfunction generateRandomCryptoString(): string {\n  return Array.from(crypto.randomBytes(64))\n    .map(x => x.toString(16).padStart(2, '0'))\n    .join('');\n}\n\nfunction log(res: express.Response, ...args: any[]) {\n  console.log(getClientStr(res), ...args);\n}\n\nfunction logDebug(res: express.Response, ...args: any[]) {\n  if (process.env.NODE_ENV === 'development') {\n    log(res, ...args);\n  }\n}\n\nfunction logError(res: express.Response, ...args: any[]) {\n  console.error(getClientStr(res), ...args);\n}\n\nfunction getClientStr(res: express.Response): string {\n  if (process.env.NODE_ENV === 'development') return '';\n\n  const client = res.locals?.client;\n  return `${client ? `${client.username} (${client.token.slice(0, 5)}):` : 'anonymous:'}`;\n}\n"
  },
  {
    "path": "src/server/validateEnvVars.ts",
    "content": "const keys = [\n  'PORT',\n  'NODE_ENV',\n  'DISABLE_CACHE',\n  'LOG_TO_CONSOLE',\n  'FORWARD_LOGS_TO_SERVER',\n  'FORWARD_ERRORS_TO_SERVER',\n] as const;\n\ntype KeyType = (typeof keys)[number];\n\ndeclare global {\n  namespace NodeJS {\n    interface ProcessEnv extends Record<KeyType, string> {}\n  }\n}\n\nfor (const key of keys) {\n  if (!process.env[key])\n    throw new Error(\n      `The environment variable ${key} is missing. Consider using a .env file at the root of the project.`,\n    );\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\",\n    \"module\": \"nodenext\", // Depends on moduleResolution.\n    \"target\": \"esnext\",\n    \"lib\": [\"esnext\", \"dom\", \"dom.iterable\", \"WebWorker\"],\n    \"jsx\": \"preserve\",\n    \"isolatedModules\": true, // Prevents you from using features which could cause mis-compilation in environments like esbuild where each file is compiled independently without tracing type references across files.\n    \"declaration\": true, //  Defaults to true when composite: true\n    \"sourceMap\": true,\n    \"strict\": true, // Enable all strict type-checking options\n    \"moduleResolution\": \"nodenext\", // nodenext to make imports and exports in package.json possible.\n    \"noFallthroughCasesInSwitch\": true, // Report errors for fallthrough cases in switch statement.\n    \"esModuleInterop\": true,\n    \"incremental\": true, // Default is true when composite: true\n    \"composite\": true, // The composite option enforces certain constraints which make it possible for build tools (including TypeScript itself, under --build mode) to quickly determine if a project has been built yet.\n    \"useDefineForClassFields\": true,\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"resolveJsonModule\": true,\n    \"noUnusedParameters\": false,\n    \"noUnusedLocals\": false\n  },\n  \"include\": [\"src\"]\n}\n"
  }
]