Full Code of computing-den/unforget for AI

master e519aca810b4 cached
64 files
241.5 KB
63.3k tokens
349 symbols
1 requests
Download .txt
Showing preview only (258K chars total). Download the full file or copy to clipboard to get everything.
Repository: computing-den/unforget
Branch: master
Commit: e519aca810b4
Files: 64
Total size: 241.5 KB

Directory structure:
gitextract_bzf8m7j0/

├── .gitignore
├── .ignore
├── .prettierrc
├── LICENSE.txt
├── README.md
├── esbuild.config.mjs
├── examples/
│   ├── example.py
│   └── example.ts
├── package.json
├── public/
│   └── manifest.json
├── scripts/
│   ├── script.ts
│   └── tsconfig.json
├── src/
│   ├── client/
│   │   ├── AboutPage.tsx
│   │   ├── App.tsx
│   │   ├── DemoPage.tsx
│   │   ├── Editor.css
│   │   ├── Editor.tsx
│   │   ├── ExportPage.tsx
│   │   ├── ImportPage.tsx
│   │   ├── LoginPage.css
│   │   ├── LoginPage.tsx
│   │   ├── Menu.css
│   │   ├── Menu.tsx
│   │   ├── NotePage.css
│   │   ├── NotePage.tsx
│   │   ├── Notes.css
│   │   ├── Notes.tsx
│   │   ├── NotesPage.css
│   │   ├── NotesPage.tsx
│   │   ├── Notifications.css
│   │   ├── Notifications.tsx
│   │   ├── PageLayout.css
│   │   ├── PageLayout.tsx
│   │   ├── api.ts
│   │   ├── appStore.tsx
│   │   ├── appStoreActions.tsx
│   │   ├── clientToServiceWorkerApi.ts
│   │   ├── common.css
│   │   ├── cookies.ts
│   │   ├── cross-context-broadcast.ts
│   │   ├── crypto.ts
│   │   ├── custom.d.ts
│   │   ├── hooks.tsx
│   │   ├── icons.ts
│   │   ├── index.tsx
│   │   ├── logger.ts
│   │   ├── normalize.css
│   │   ├── notes/
│   │   │   ├── about.md
│   │   │   ├── export.md
│   │   │   ├── import.md
│   │   │   └── welcome1.md
│   │   ├── router.tsx
│   │   ├── serviceWorker.ts
│   │   ├── serviceWorkerToClientApi.ts
│   │   ├── storage.ts
│   │   ├── style.css
│   │   └── sync.ts
│   ├── common/
│   │   ├── mdFns.ts
│   │   ├── types.ts
│   │   └── util.ts
│   └── server/
│       ├── db.ts
│       ├── index.ts
│       └── validateEnvVars.ts
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
node_modules
dist
private
tsconfig.tsbuildinfo
.env
deploy
*#
/examples/credentials.json


================================================
FILE: .ignore
================================================
package-lock.json

================================================
FILE: .prettierrc
================================================
{
  "trailingComma": "all",
  "bracketSpacing": true,
  "printWidth": 120,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": true,
  "jsxBracketSameLine": false,
  "arrowParens": "avoid"
}


================================================
FILE: LICENSE.txt
================================================
Copyright (c) 2024 Computing Den, https://computing-den.com

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: README.md
================================================
# Unforget

![screenshot](doc/screenshots.png)

*Start now without registering at [unforget.computing-den.com](https://unforget.computing-den.com/demo).*

Unforget is a minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js) featuring:

- [x] Offline first
- [x] Privacy first
- [x] Progressive web app
- [x] Open source MIT License
- [x] End-to-end encrypted sync
- [x] Desktop, Mobile, Web
- [x] Markdown support
- [x] Self hosted and cloud options
- [x] One-click data export as JSON
- [x] Optional one-click installation
- [x] Public APIs, create your own client
- [x] Import Google Keep
- [x] Import Apple Notes
- [x] Import Standard Notes


*Unforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.*

*Contact us at sean@computing-den.com*


# Easy Signup

[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.

*No email or phone required.*

# Optional installation

Use it directly in your browser or install:

| Browser         | Installation                |
|-----------------|-----------------------------|
| Chrome          | Install icon in the URL bar |
| Edge            | Install icon in the URL bar |
| Android Browser | Menu → Add to Home Screen   |
| Safari Desktop  | Share → Add to Dock         |
| Safari iOS      | Share → Add to Home Screen  |
| Firefox Desktop | *cannot install*            |
| Firefox Android | Install icon in the URL bar |

# Organization and Workflow

Notes are organized **chronologically**, with pinned notes displayed at the top.

This 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.

There 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.

Notes are **immediately saved** as you type and synced every few seconds.

If you edit a note from two devices and a **conflict** occurs during sync, the most recent edit will take precedence.

# Security and Privacy

Unforget 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.

Only your username and note modification dates are visible to Unforget servers.

# Text Formatting

The main differences with the [Github flavored markdown](https://github.github.com/gfm/) are:
- If the first line of a note is followed by a blank line, it is a H1 header.
- Anything after the first horizontal rule `---` in a note will be hidden and replaced with a "show more" button that will expand the note.

~~~
# H1 header
## H2 header
### H3 header
#### H4 header
##### H5 header
###### H6 header

*This is italic.*.

**This is bold.**.

***This is bold and italic.***

~~This is strikethrough~~


- This is a bullet point
- Another bullet point
  - Inner bullet point
- [ ] This is a checkbox
  And more text related to the checkbox.

1. This is an ordered list item
2. And another one

[this is a link](https://unforget.computing-den.com)

Inline `code` using back-ticks.

Block of code:

```javascript
function plusOne(a) {
  return a + 1;
}
```


| Tables        | Are           | Cool  |
| ------------- |:-------------:| -----:|
| col 3 is      | right-aligned | $1600 |
| col 2 is      | centered      |   $12 |


Horizontal rule:

---


~~~

# Build and Self Host

To build Unforget for production, put a `.env` file in the project's root directory:

```
PORT=3000
NODE_ENV=production
DISABLE_CACHE=0
LOG_TO_CONSOLE=0
FORWARD_LOGS_TO_SERVER=0
FORWARD_ERRORS_TO_SERVER=0
```

and then run

```
cd unforget/
npm run build
npm run start

```

It 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/).

# Development

To build and run Unforget in development mode, put a `.env` file in the project's root directory:

```
PORT=3000
NODE_ENV=development
DISABLE_CACHE=1
LOG_TO_CONSOLE=1
FORWARD_LOGS_TO_SERVER=0
FORWARD_ERRORS_TO_SERVER=0
```

and then run

```
cd unforget/
npm install
npm run dev

```

This will build the project and watch for changes in the source files.

# Public APIs - write your own client

Here, 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.

## Examples

In the [examples/](examples/) directory you will find example code for TypeScript and Python.

To run the **Typescript** example:

``` bash
cd examples/

# Signup
npx tsx example.ts signup USERNAME PASSWORD

# Login
npx tsx example.ts login USERNAME PASSWORD

# Create new note
npx tsx example.ts create "Hello world!"

# Get all notes
npx tsx example.ts get

# Get note by ID
npx tsx example.ts get ID
```

To run the **Python** example:

``` bash
cd examples/

# Signup
python3 example.py signup USERNAME PASSWORD

# Login
python3 example.py login USERNAME PASSWORD

# Create new note
python3 example.py create "Hello world!"

# Get all notes
python3 example.py get

# Get note by ID
python3 example.py get ID
```

## Note Types

```ts
type Note = {

  // UUID version 4
  id: string;

  // Deleted notes have text: null
  text: string | null;

  // ISO 8601 format
  creation_date: string;
  
  // ISO 8601 format
  modification_date: string;
  
  // 0 means deleted, 1 means not deleted
  not_deleted: number;
  
  // 0 means archived, 1 means not archived
  not_archived: number;
  
  // 0 means not pinned, 1 means pinned
  pinned: number;

  // A higher number means higher on the list
  // Usually, by default it's milliseconds since the epoch
  order: number;

}

type EncryptedNote = {

  // UUID version 4
  id: string;

  // ISO 8601 format
  modification_date: string;
  
  // The encrypted Note in base64 format
  encrypted_base64: string;
  
  // Initial vector, a random number, that was used for encrypting this specific note
  iv: string;

}

```

The 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.

Side 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.


## Signup, Login, Logout

To sign up, send a POST request to ```/api/signup``` with a JSON payload of type ```SignupData```:

```ts
type SignupData = {
  username: string;
  password_client_hash: string;
  encryption_salt: string;
}
```

To log in, send a POST request to ```/api/login``` with a JSON payload of type ```LoginData```:

```ts
type LoginData = {
  username: string;
  password_client_hash: string;
}
```

In both cases, if the credentials are wrong you will receive a 401 error. Otherwise, the server will respond with ```LoginResponse``` and code 200:

```ts
type LoginResponse = {
  username: string;
  token: string;
  encryption_salt: string;
}
```

To log out, send a POST request to ```/api/login?token=TOKEN```

In 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```.

Notice 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.

## Get Notes

Send 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.

You will receive ```EncryptedNote[]```.

## Merge Notes

Send a POST request to ```/api/merge-notes?token=TOKEN``` with a JSON payload of type ```{notes: EncryptedNote[]}```.

If the note doesn't already exist, it will be added.
If its ```modification_date``` is larger than the existing note, it will replace the existing note.
Otherwise, it will be thrown away.

## Delete Notes

To 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.

## Sync and Merge

For a long-running client, instead of using [Get Notes](#get-notes) and [Merge Notes](#merge-notes), you can use sync in the following manner.

The 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**.

The 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.

A **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.

When the sync number is 0 (immediately after login), the server will send all notes in the first delta sync.

To perform a **delta sync**, send a POST request to ```/api/delta-sync?token=TOKEN``` with a JSON payload of type ```SyncData```:

```ts
type SyncData = {
  notes: EncryptedNote[];
  syncNumber: number;
}
```

If 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.

```ts
type DeltaSyncResNormal = {
  type: 'ok';
  notes: EncryptedNote[];
  syncNumber: number;
}

type DeltaSyncResRequireQueueSync = {
  type: 'require_queue_sync';
}
```

To 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.

```ts
type SyncHeadsData = {
  noteHeads: NoteHead[];
  syncNumber: number;
}

type NoteHead = {
  id: string;
  modification_date: string;
}
```

After 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.

It 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```.

## Encryption and Decryption

The details of encryption and decryption are more easily explained in code. See the [Examples](#examples) section.

## Error handling

All the API calls will return an object of type ```ServerError``` when encountering an error with a status code >= 400:

```ts
type ServerError {
  message: string;
  code: number;
  type: 'app_requires_update' | 'generic';
}
```

If 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.


================================================
FILE: esbuild.config.mjs
================================================
import 'dotenv/config';
import esbuild from 'esbuild';

const context = await esbuild.context({
  entryPoints: ['src/client/index.tsx', 'src/client/style.css', 'src/client/serviceWorker.ts'],
  outdir: 'dist/public',
  minify: process.env.NODE_ENV === 'production',
  bundle: true,
  sourcemap: true,
  format: 'esm',
  treeShaking: true,
  define: Object.fromEntries(Object.keys(process.env).map(key => [`process.env.${key}`, `"${process.env[key]}"`])),
  plugins: [reporterPlugin()],
  loader: { '.svg': 'dataurl', '.txt': 'text', '.md': 'text' },
});

if (process.argv.includes('--watch')) {
  console.log('Watching ...');
  await context.watch();
} else {
  console.log('Building ...');
  await context.rebuild();
  await context.dispose();
}

function reporterPlugin() {
  return {
    name: 'reporter',
    setup(build) {
      build.onEnd(result => console.log(`Done - ${result.errors.length} errors, ${result.warnings.length} warnings`));
    },
  };
}


================================================
FILE: examples/example.py
================================================
# The following was generated by ChatGPT4o from the original example.ts with some trial and error.
# It was manually inspected and tested.

import json
import sys
import uuid
import requests
import hashlib
from base64 import b64encode, b64decode, urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime
import os
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

BASE_URL = 'https://unforget.computing-den.com'

class Note:
    def __init__(self, id, text, creation_date, modification_date, not_deleted, not_archived, pinned, order):
        self.id = id
        self.text = text
        self.creation_date = creation_date
        self.modification_date = modification_date
        self.not_deleted = not_deleted
        self.not_archived = not_archived
        self.pinned = pinned
        self.order = order

class EncryptedNote:
    def __init__(self, id, modification_date, encrypted_base64, iv):
        self.id = id
        self.modification_date = modification_date
        self.encrypted_base64 = encrypted_base64
        self.iv = iv

class LoginData:
    def __init__(self, username, password_client_hash):
        self.username = username
        self.password_client_hash = password_client_hash

class SignupData(LoginData):
    def __init__(self, username, password_client_hash, encryption_salt):
        super().__init__(username, password_client_hash)
        self.encryption_salt = encryption_salt

class LoginResponse:
    def __init__(self, username, token, encryption_salt):
        self.username = username
        self.token = token
        self.encryption_salt = encryption_salt

class Credentials(LoginResponse):
    def __init__(self, username, token, encryption_salt, jwk):
        super().__init__(username, token, encryption_salt)
        self.jwk = jwk

def main():
    if len(sys.argv) < 2:
        usage_and_exit()

    command = sys.argv[1]

    if command == 'signup':
        if len(sys.argv) != 4:
            usage_and_exit()
        username = sys.argv[2]
        password = sys.argv[3]
        signup(username, password)
    elif command == 'login':
        if len(sys.argv) != 4:
            usage_and_exit()
        username = sys.argv[2]
        password = sys.argv[3]
        login(username, password)
    elif command == 'create':
        if len(sys.argv) != 3:
            usage_and_exit()
        text = sys.argv[2]
        create_note(text)
    elif command == 'get':
        id = sys.argv[2] if len(sys.argv) == 3 else None
        get_note(id)
    else:
        usage_and_exit()
    print('Success.')

def usage_and_exit():
    print("""
Usage: python3 script.py COMMAND
Available commands:
  signup USERNAME PASSWORD
  login USERNAME PASSWORD
  create TEXT
  get [ID]
""")
    sys.exit(1)

def signup(username, password):
    # Generate a random salt for encryption key derivation
    salt = bytes_to_hex_string(os.urandom(16))
    # Calculate password hash
    hash = calc_password_hash(username, password)
    # Prepare signup data
    data = SignupData(username, hash, salt)
    # Send signup request and handle response
    res_dict = post('/api/signup', data.__dict__)
    res = LoginResponse(res_dict['username'], res_dict['token'], res_dict['encryption_salt'])
    # Create credentials including derived encryption key
    credentials = create_credentials(res, password)
    # Save credentials to file
    write_credentials(credentials)

def login(username, password):
    # Calculate password hash
    hash = calc_password_hash(username, password)
    # Prepare login data
    data = LoginData(username, hash)
    # Send login request and handle response
    res_dict = post('/api/login', data.__dict__)
    res = LoginResponse(res_dict['username'], res_dict['token'], res_dict['encryption_salt'])
    # Create credentials including derived encryption key
    credentials = create_credentials(res, password)
    # Save credentials to file
    write_credentials(credentials)

def create_note(text):
    # Create a new note
    note = Note(
        str(uuid.uuid4()),
        text,
        datetime.utcnow().isoformat(),
        datetime.utcnow().isoformat(),
        1, 1, 0,
        int(datetime.utcnow().timestamp() * 1000)
    )
    # Read credentials from file
    credentials = read_credentials()
    # Import the encryption key from credentials
    key = import_key(credentials)
    # Encrypt the note
    encrypted_note = encrypt_note(note, key)
    # Send the encrypted note to the server
    post('/api/merge-notes', {'notes': [encrypted_note.__dict__]}, credentials)
    print(f'Create note with ID {note.id}')

def get_note(id):
    # Read credentials from file
    credentials = read_credentials()
    # Import the encryption key from credentials
    key = import_key(credentials)
    # Prepare list of note IDs to retrieve (or None to get all)
    ids = [id] if id else None
    # Retrieve encrypted notes from the server
    encrypted_notes = post('/api/get-notes', {'ids': ids}, credentials)
    if not encrypted_notes:
        print('Not found')
    else:
        # Decrypt the received notes
        notes = [decrypt_note(EncryptedNote(**note), key) for note in encrypted_notes]
        # Print the decrypted notes
        for note in notes:
            print(json.dumps(note.__dict__, indent=2) + '\n')

def encrypt_note(note, key):
    # Convert the note to bytes
    data = json.dumps(note.__dict__).encode()
    # Generate a random initialization vector (IV)
    iv = os.urandom(12)
    # Create an encryptor with AES-GCM mode
    encryptor = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()).encryptor()
    # Encrypt the note data
    encrypted = encryptor.update(data) + encryptor.finalize()
    # Combine the encrypted data and tag, then encode in base64
    encrypted_base64 = b64encode(encrypted + encryptor.tag).decode('utf-8')
    # Return an EncryptedNote object
    return EncryptedNote(note.id, note.modification_date, encrypted_base64, bytes_to_hex_string(iv))

def decrypt_note(encrypted_note, key):
    # Decode the base64-encoded encrypted data
    encrypted_bytes_with_tag = b64decode(encrypted_note.encrypted_base64)
    # Extract the IV and tag from the encrypted data
    iv = hex_string_to_bytes(encrypted_note.iv)
    encrypted_bytes, tag = encrypted_bytes_with_tag[:-16], encrypted_bytes_with_tag[-16:]
    # Create a decryptor with AES-GCM mode
    decryptor = Cipher(algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend()).decryptor()
    # Decrypt the note data
    decrypted_bytes = decryptor.update(encrypted_bytes) + decryptor.finalize()
    # Convert the decrypted bytes back to a string and parse as JSON
    note_string = decrypted_bytes.decode('utf-8')
    return Note(**json.loads(note_string))

def read_credentials():
    # Read and parse the credentials from a file
    with open('credentials.json', 'r') as file:
        return Credentials(**json.load(file))

def write_credentials(credentials):
    # Write the credentials to a file
    with open('credentials.json', 'w') as file:
        json.dump(credentials.__dict__, file, indent=2)
    print('Wrote credentials to ./credentials.json')

def import_key(credentials):
    # Decode the base64-encoded key from the credentials
    key = urlsafe_b64decode(credentials.jwk['k'] + '==')
    return key

def create_credentials(res, password):
    # Derive a PBKDF2 key from the password and the encryption salt
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=bytes.fromhex(res.encryption_salt),
        iterations=100000,
        backend=default_backend()
    )
    key = kdf.derive(password.encode())
    # Create a JSON Web Key (JWK) from the derived key
    jwk = {'key_ops': ['encrypt', 'decrypt'], 'ext': True, 'kty': 'oct', 'k': urlsafe_b64encode(key).decode('utf-8').rstrip('=')}
    # Return credentials including the JWK
    return Credentials(res.username, res.token, res.encryption_salt, jwk)

def post(pathname, body, credentials=None):
    # Construct the full URL with the optional token as a query parameter
    url = f'{BASE_URL}{pathname}'
    if credentials:
        url += f'?token={credentials.token}'
    # Send a POST request with the provided body
    headers = {'Content-Type': 'application/json'}
    response = requests.post(url, headers=headers, json=body)
    response.raise_for_status()
    return response.json()

def calc_password_hash(username, password):
    # Calculate a SHA-256 hash of the username, password, and a static random number
    text = f'{username}{password}32261572990560219427182644435912532'
    hash_buf = hashlib.sha256(text.encode()).digest()
    return bytes_to_hex_string(hash_buf)

def bytes_to_hex_string(bytes):
    # Convert a byte array to a hex string
    return ''.join(f'{byte:02x}' for byte in bytes)

def hex_string_to_bytes(hex_string):
    # Convert a hex string to a byte array
    if len(hex_string) % 2 != 0:
        raise ValueError('Invalid hex string')
    return bytes.fromhex(hex_string)

if __name__ == "__main__":
    main()


================================================
FILE: examples/example.ts
================================================
import { webcrypto } from 'node:crypto';
import fs from 'node:fs';

type Note = {
  // UUID version 4
  id: string;

  // Deleted notes have null text
  text: string | null;

  // ISO 8601 format
  creation_date: string;

  // ISO 8601 format
  modification_date: string;

  // 0 means deleted, 1 means not deleted
  not_deleted: number;

  // 0 means archived, 1 means not archived
  not_archived: number;

  // 0 means not pinned, 1 means pinned
  pinned: number;

  // A higher number means higher on the list
  // Usually, by default it's milliseconds since the epoch
  order: number;
};

type EncryptedNote = {
  // UUID version 4
  id: string;

  // ISO 8601 format
  modification_date: string;

  // The encrypted Note in base64 format
  encrypted_base64: string;

  // Initial vector, a random number, that was used for encrypting this specific note
  iv: string;
};

type LoginData = {
  username: string;
  password_client_hash: string;
};

type SignupData = {
  username: string;
  password_client_hash: string;
  encryption_salt: string;
};

type LoginResponse = {
  username: string;
  token: string;
  encryption_salt: string;
};

// In addition to LoginResponse, we want to locally store the CryptoKey which is derived from
// the encryption salt and the raw password during login/signup and used for encryption/decryption.
// However, since CryptoKey is not directly serializable, we convert it to JsonWebKey and use
// importKey() to convert back later.
type Credentials = LoginResponse & { jwk: webcrypto.JsonWebKey };

const BASE_URL = 'https://unforget.computing-den.com';

async function main() {
  switch (process.argv[2]) {
    case 'signup': {
      const username = process.argv[3];
      const password = process.argv[4];
      if (!username || !password) usageAndExit();

      await signup(username, password);
      break;
    }
    case 'login': {
      const username = process.argv[3];
      const password = process.argv[4];
      if (!username || !password) usageAndExit();

      await login(username, password);
      break;
    }
    case 'create': {
      const text = process.argv[3];
      if (!text) usageAndExit();

      await createNote(text);
      break;
    }
    case 'get': {
      const id = process.argv[3];

      await getNote(id);
      break;
    }
    default:
      usageAndExit();
  }
  console.log('Success.');
}

function usageAndExit() {
  console.error(`
Usage: npx tsx example.ts COMMAND
Available commands:
  singup USERNAME PASSWORD
  login USERNAME PASSWORD
  create TEXT
  get [ID]
`);
  process.exit(1);
}

async function signup(username: string, password: string) {
  const salt = bytesToHexString(webcrypto.getRandomValues(new Uint8Array(16)));
  const hash = await calcPasswordHash(username, password);
  const data: SignupData = { username, password_client_hash: hash, encryption_salt: salt };
  const res = await post<LoginResponse>('/api/signup', data);
  const credentials = await createCredentials(res, password);
  writeCredentials(credentials);
}

async function login(username: string, password: string) {
  const hash = await calcPasswordHash(username, password);
  const data: LoginData = { username, password_client_hash: hash };
  const res = await post<LoginResponse>('/api/login', data);
  const credentials = await createCredentials(res, password);
  writeCredentials(credentials);
}

async function createNote(text: string) {
  const note: Note = {
    id: webcrypto.randomUUID(),
    text,
    creation_date: new Date().toISOString(),
    modification_date: new Date().toISOString(),
    not_deleted: 1,
    not_archived: 1,
    pinned: 0,
    order: Date.now(),
  };

  // Read the credentials and convert the key from JsonWebKey back to CryptoKey.
  const credentials = readCredentials();
  const key = await importKey(credentials);

  const encryptedNote = await encryptNote(note, key);
  await post(`/api/merge-notes`, { notes: [encryptedNote] }, credentials);
  console.log(`Created note with ID ${note.id}`);
}

async function getNote(id?: string) {
  // Read the credentials and convert the key from JsonWebKey back to CryptoKey.
  const credentials = readCredentials();
  const key = await importKey(credentials);

  // ids: [] would return no notes. ids: undefined or null would return everything.
  const ids = id ? [id] : null;
  const encryptedNotes = await post<EncryptedNote[]>(`/api/get-notes`, { ids }, credentials);

  if (encryptedNotes.length === 0) {
    console.log('Not found');
  } else {
    // Decrypt the received notes using the key.
    const notes = await Promise.all(encryptedNotes.map(x => decryptNote(x, key)));
    // Log to console.
    for (const note of notes) console.log(JSON.stringify(note, null, 2) + '\n');
  }
}

async function encryptNote(note: Note, key: webcrypto.CryptoKey): Promise<EncryptedNote> {
  // Encode the string to bytes.
  const data = new TextEncoder().encode(JSON.stringify(note));

  // Generate the initial vector (iv).
  const iv = webcrypto.getRandomValues(new Uint8Array(12));

  // Encrypt the bytes using the iv and the given key.
  const encrypted = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);

  // Encode as base64 to easily store in JSON.
  const encryptedBase64 = Buffer.from(encrypted).toString('base64');

  // Create the EncryptedNote object.
  return {
    id: note.id,
    modification_date: note.modification_date,
    encrypted_base64: encryptedBase64,
    iv: bytesToHexString(iv),
  };
}

async function decryptNote(encryptedNote: EncryptedNote, key: webcrypto.CryptoKey): Promise<Note> {
  // Decode the base64 string to bytes.
  const encryptedBytes = Buffer.from(encryptedNote.encrypted_base64, 'base64');

  // Decrypt the bytes using note's initial vector (iv) and the given key.
  const iv = hexStringToBytes(encryptedNote.iv);
  const decryptedBytes = await webcrypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes);

  // Decode the decrypted bytes into string.
  const noteString = new TextDecoder().decode(decryptedBytes);

  // Parse the string to get the note JSON.
  return JSON.parse(noteString);
}

/**
 * Read the credentials from ./credentials.json
 */
function readCredentials(): Credentials {
  return JSON.parse(fs.readFileSync('./credentials.json', 'utf8'));
}

/**
 * Write the credentials to ./credentials.json.
 */
function writeCredentials(credentials: Credentials) {
  fs.writeFileSync('credentials.json', JSON.stringify(credentials, null, 2));
  console.log('Wrote credentials to ./credentials.json');
}

/**
 * Converts the JsonWebKey (credentials.jwk) which was exported from CryptoKey back to CryptoKey so
 * that it can be used for encrypting and decrypting notes.
 */
async function importKey(credentials: Credentials): Promise<CryptoKey> {
  return webcrypto.subtle.importKey('jwk', credentials.jwk, 'AES-GCM', true, ['encrypt', 'decrypt']);
}

/**
 * It derives a PBKDF2 CryptoKey from the password and the res.encryption_salt for encrypting and decrypting notes.
 * The CryptoKey is then exported to JsonWebKey so that we can serialize it and store it in credentials.json.
 * Use importKey() to convert back to CryptoKey.
 */
async function createCredentials(res: LoginResponse, password: string): Promise<Credentials> {
  const keyData = new TextEncoder().encode(password);
  const keyMaterial = await webcrypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']);

  const saltBuf = hexStringToBytes(res.encryption_salt);
  const key = await webcrypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: saltBuf,
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt'],
  );

  const jwk = await webcrypto.subtle.exportKey('jwk', key);
  return { ...res, jwk };
}

/**
 * Send a POST request to BASE_URL and parse the resopnse as JSON.
 */
async function post<T>(pathname: string, body?: any, credentials?: Credentials): Promise<T> {
  const query = credentials ? `?token=${credentials.token}` : '';
  const url = `${BASE_URL}${pathname}${query}`;
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: body && JSON.stringify(body),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

/**
 * The password hash is derived from the username, password, and a specific static random number.
 * It is important to use the exact same method for calculating the hash if you wish the
 * credentials to work with the official unforget app.
 */
async function calcPasswordHash(username: string, password: string): Promise<string> {
  const text = username + password + '32261572990560219427182644435912532';
  const encoder = new TextEncoder();
  const textBuf = encoder.encode(text);
  const hashBuf = await webcrypto.subtle.digest('SHA-256', textBuf);
  return bytesToHexString(new Uint8Array(hashBuf));
}

/**
 * bytesToHexString(Uint8Array.from([1, 2, 3, 10, 11, 12])) //=> '0102030a0b0c'
 */
function bytesToHexString(bytes: Uint8Array): string {
  return Array.from(bytes)
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');
}

/**
 * hexStringToBytes('0102030a0b0c') //=> Uint8Array(6) [ 1, 2, 3, 10, 11, 12 ]
 */
function hexStringToBytes(str: string): Uint8Array {
  if (str.length % 2) throw new Error('hexStringToBytes invalid string');
  const bytes = new Uint8Array(str.length / 2);
  for (let i = 0; i < str.length; i += 2) {
    bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
  }
  return bytes;
}

main();


================================================
FILE: package.json
================================================
{
  "name": "unforget",
  "title": "Unforget",
  "version": "1.0.0",
  "description": "A minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js)",
  "type": "module",
  "scripts": {
    "start": "node --enable-source-maps dist/server/index.js",
    "clean": "barefront clean",
    "build": "barefront build",
    "dev": "barefront dev",
    "init-server": "barefront init-server",
    "clean-server": "barefront clean-server",
    "deploy": "barefront deploy"
  },
  "exports": {
    "./*": "./dist/*"
  },
  "barefront": {
    "library": false
  },
  "author": "Sean Shirazi <sean@computing-den.com>",
  "keywords": [
    "PWA",
    "Progressive Web App",
    "React",
    "SaaS",
    "note-taking",
    "note",
    "todo",
    "minimalist",
    "minimal",
    "simple",
    "E2EE",
    "privacy",
    "offline",
    "encrypted"
  ],
  "license": "MIT",
  "homepage": "https://unforget.computing-den.com/demo",
  "repository": "github:computing-den/unforget",
  "engines": {
    "node": ">=18.19.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.9",
    "@types/body-parser": "^1.19.5",
    "@types/cookie-parser": "^1.4.6",
    "@types/express": "^4.17.21",
    "@types/lodash": "^4.14.202",
    "@types/node": "^20.11.20",
    "@types/react": "^18.2.57",
    "@types/react-dom": "^18.2.19",
    "@types/uuid": "^9.0.8",
    "barefront": "2.14.0",
    "esbuild": "^0.25.8",
    "typescript": "^5.9.2"
  },
  "dependencies": {
    "better-sqlite3": "^9.4.3",
    "body-parser": "^1.20.2",
    "cookie-parser": "^1.4.6",
    "dotenv": "^16.4.5",
    "express": "^4.18.2",
    "hast-util-to-html": "^9.0.1",
    "immer": "^10.0.3",
    "lodash": "^4.17.21",
    "mdast-util-from-markdown": "^2.0.0",
    "mdast-util-gfm": "^3.0.0",
    "mdast-util-newline-to-break": "^2.0.0",
    "mdast-util-to-hast": "^13.1.0",
    "micromark-extension-gfm": "^3.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "unist-util-visit": "^5.0.0",
    "unist-util-visit-parents": "^6.0.1",
    "unzipit": "^1.4.3",
    "uuid": "^9.0.1"
  }
}


================================================
FILE: public/manifest.json
================================================
{
  "name": "Unforget",
  "short_name": "Unforget",
  "description": "Never forget a thing.",
  "theme_color": "#448199",
  "background_color": "#448199",
  "display": "standalone",
  "orientation": "portrait",
  "scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    }
  ],
  "splash_pages": null
}


================================================
FILE: scripts/script.ts
================================================
let hello: string = 'Hello';

console.log(hello);


================================================
FILE: scripts/tsconfig.json
================================================
{
  "extends": "@tsconfig/node18/tsconfig.json"
}


================================================
FILE: src/client/AboutPage.tsx
================================================
import React from 'react';
import * as appStore from './appStore.jsx';
import { createNewNote, CACHE_VERSION } from '../common/util.js';
import { PageLayout, PageHeader, PageBody } from './PageLayout.jsx';
import { Notes } from './Notes.jsx';
import _ from 'lodash';
import aboutMd from './notes/about.md';

const technicalDetails = `\n\n# Technical details\n\nCache version: ${CACHE_VERSION}`;
const aboutNote = createNewNote(aboutMd + technicalDetails);

function AboutPage() {
  const app = appStore.use();

  return (
    <PageLayout>
      <PageHeader title="/ about" compact={!app.user} />
      <PageBody>
        <div className="page">
          <Notes notes={[aboutNote]} readonly />
        </div>
      </PageBody>
    </PageLayout>
  );
}

export default AboutPage;


================================================
FILE: src/client/App.tsx
================================================
import { Router, Route, useRouter } from './router.jsx';
import React, { useEffect } from 'react';
import * as appStore from './appStore.js';
import log from './logger.js';
import LoginPage from './LoginPage.jsx';
import AboutPage from './AboutPage.jsx';
import DemoPage from './DemoPage.jsx';
import { NotesPage, notesPageLoader } from './NotesPage.jsx';
import { NotePage, notePageLoader } from './NotePage.jsx';
import { ImportPage } from './ImportPage.jsx';
import { ExportPage } from './ExportPage.jsx';
import Notifications from './Notifications.jsx';
import _ from 'lodash';

export default function App() {
  const routes: Route[] = [
    {
      path: '/login',
      element: <LoginPage />,
    },
    {
      path: '/about',
      element: <AboutPage />,
    },
    {
      path: '/demo',
      element: <DemoPage />,
    },
    {
      path: '/n/:noteId',
      element: ({ params }) => (
        <Auth>
          <NotePage key={params.noteId as string} />
        </Auth>
      ),
      loader: notePageLoader,
    },
    {
      path: '/import',
      element: (
        <Auth>
          <ImportPage key="/import" />
        </Auth>
      ),
    },
    {
      path: '/export',
      element: (
        <Auth>
          <ExportPage key="/export" />
        </Auth>
      ),
    },
    {
      path: '/archive',
      element: (
        <Auth>
          <NotesPage key="/archive" />
        </Auth>
      ),
      loader: notesPageLoader,
    },
    {
      path: '/',
      element: (
        <Auth>
          <NotesPage key="/" />
        </Auth>
      ),
      loader: notesPageLoader,
    },
  ];

  return (
    <>
      <Router routes={routes} fallback={<Fallback />} />
      <Notifications />
    </>
  );
}

function Fallback() {
  return null;
}

function Auth(props: { children: React.ReactNode }) {
  const router = useRouter();
  const app = appStore.use();

  useEffect(() => {
    if (!app.user) {
      let params = '';
      if (router.pathname !== '/') {
        params = new URLSearchParams({ from: router.pathname }).toString();
      }
      const url = '/login' + (params ? `?${params}` : '');
      history.replaceState(null, '', url);
    }
  }, [app.user, router]);

  return app.user ? props.children : null;
}


================================================
FILE: src/client/DemoPage.tsx
================================================
import * as appStore from './appStore.js';
import * as actions from './appStoreActions.jsx';
import _ from 'lodash';

function DemoPage() {
  const user = appStore.get().user;
  if (!user || user.username === 'demo') {
    actions.setUpDemo().then(() => history.replaceState(null, '', '/'));
  } else {
    history.replaceState(null, '', '/');
  }

  return null;
}

export default DemoPage;


================================================
FILE: src/client/Editor.css
================================================
textarea.editor {
  /* background: #eee; */
  padding: 1rem 1rem;
  /* margin: 5rem; */
  /* width: 100px; */
  font-family: inherit;
  /* font-variant-ligatures: no-common-ligatures; */
  white-space: pre-wrap;
  word-break: break-word;
  word-wrap: break-word;
  /* line-height: var(--note-line-height); */
  resize: vertical;
  font-family: monospace;
}


================================================
FILE: src/client/Editor.tsx
================================================
import log from './logger.js';
import React, { useState, useLayoutEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import type * as t from '../common/types.js';
import * as cutil from '../common/util.js';
import * as md from '../common/mdFns.js';
import { MenuItem } from './Menu.js';
import { useClickWithoutDrag } from './hooks.jsx';
import _ from 'lodash';
import { v4 as uuid } from 'uuid';

type EditorProps = {
  value: string;
  onChange: (value: string) => any;
  id?: string;
  className?: string;
  placeholder?: string;
  // autoFocus?: boolean;
  readOnly?: boolean;
  onFocus?: React.FocusEventHandler<HTMLTextAreaElement>;
  onBlur?: React.FocusEventHandler<HTMLTextAreaElement>;
  autoExpand?: boolean;
  // onConfirm: () => any;
  // onDelete: () => any;
  // onTogglePinned: () => any;
};

export type EditorContext = {
  cycleListStyle: () => any;
  focus: () => any;
  textareaRef: React.RefObject<HTMLTextAreaElement>;
};

type Selection = { start: number; end: number; direction: 'forward' | 'backward' | 'none' };

export const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorContext>) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  // const [selection, setSelection] = useState<Selection>({ start: 0, end: 0, direction: 'forward' });
  // const [lastSelection, setLastSelection] = useState<Selection | undefined>();

  function replaceText(deleteStart: number, deleteEnd: number, text: string = '') {
    const textarea = textareaRef.current!;
    const currentValue = textarea.value;
    const before = currentValue.slice(0, deleteStart);
    const after = currentValue.slice(deleteEnd);
    const newCursor = deleteStart + text.length;

    props.onChange(before + text + after);

    requestAnimationFrame(() => {
      textarea.setSelectionRange(newCursor, newCursor);
    });
  }

  function replaceListItemPrefix(listItem: md.ListItem, newListItem: md.ListItem, lineRange: md.Range) {
    const linePrefix = md.stringifyListItemPrefix(listItem);
    const newLinePrefix = md.stringifyListItemPrefix(newListItem);
    replaceText(lineRange.start, lineRange.start + linePrefix.length, newLinePrefix);
  }

  function cycleListStyle() {
    const textarea = textareaRef.current!;
    const text = textarea.value;
    // If there's not lastSelection, assume end of text
    const i = textarea.selectionStart;
    const lineRange = md.getLineRangeAt(text, i);
    const line = md.getLine(text, lineRange);
    const listItem = md.parseListItem(line);

    // console.log('lastSelection', lastSelection);

    // unstyled -> checkbox -> bulletpoint ...
    if (listItem.checkbox) {
      replaceListItemPrefix(listItem, md.removeListItemCheckbox(listItem), lineRange);
    } else if (listItem.type) {
      replaceListItemPrefix(listItem, md.removeListItemType(listItem), lineRange);
      // } else if (!lastSelection) {
      //   replaceText(text.length, text.length, text.length > 0 ? '\n- [ ] ' : '- [ ] ');
      //   textarea.setSelectionRange(textarea.value.length, textarea.value.length);
    } else {
      replaceListItemPrefix(listItem, md.addListItemCheckbox(listItem), lineRange);
    }

    textarea.focus();
    // If there were no lastSelection, move cursor to the end.
    // if (!lastSelection) {
    //   textarea.focus();
    //   textarea.setSelectionRange(textarea.value.length, textarea.value.length);
    // }
  }

  function focus() {
    textareaRef.current!.focus();
  }

  useImperativeHandle<EditorContext, EditorContext>(ref, () => ({ cycleListStyle, focus, textareaRef }), [
    cycleListStyle,
    focus,
    textareaRef,
  ]);

  function changeCb() {
    props.onChange(textareaRef.current!.value);
  }

  function keyDownCb(e: React.KeyboardEvent) {
    const textarea = textareaRef.current!;

    if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
      // textarea.focus();
      const text = textarea.value;
      const i = textarea.selectionStart;
      const lineRange = md.getLineRangeAt(text, i);
      const line = md.getLine(text, lineRange);
      const listItem = md.parseListItem(line);
      const listItemPrefix = md.stringifyListItemPrefix(listItem);

      // Ignore if cursor is before the line prefix
      if (i < lineRange.start + listItemPrefix.length) return;

      if (!listItemPrefix) return;

      e.preventDefault();
      if (listItem.content) {
        // Increment list item number and set empty checkbox.
        let newListItem = md.incrementListItemNumber(listItem);
        if (listItem.checkbox) {
          newListItem = md.setListItemCheckbox(newListItem, false);
        }

        // Delete whitespace and insert the prefix.
        const afterWhitespace = md.skipWhitespaceSameLine(text, i);
        const before = text.slice(0, i);
        const after = text.slice(afterWhitespace);
        const insert = '\n' + md.stringifyListItemPrefix(newListItem);
        const newCursor = before.length + insert.length;

        props.onChange(before + insert + after);
        requestAnimationFrame(() => {
          textarea.setSelectionRange(newCursor, newCursor);
        });
      } else {
        // Pressing enter on a line with prefix and empty content will clear the prefix.
        const before = text.slice(0, lineRange.start);
        const after = text.slice(lineRange.end);
        const newText = before + after;
        const newCursor = lineRange.start;

        props.onChange(newText);
        requestAnimationFrame(() => {
          textarea.setSelectionRange(newCursor, newCursor);
        });
      }
    }
  }

  // function selectCb() {
  //   const textarea = textareaRef.current!;
  //   // setLastSelection(selection);
  //   setSelection({
  //     start: textarea.selectionStart,
  //     end: textarea.selectionEnd,
  //     direction: textarea.selectionDirection,
  //   });
  // }

  function clickCb(e: React.MouseEvent) {
    const textarea = textareaRef.current!;
    const text = textarea.value;
    const i = textarea.selectionDirection === 'forward' ? textarea.selectionEnd : textarea.selectionStart;
    const lineRange = md.getLineRangeAt(text, i);
    const line = md.getLine(text, lineRange);
    const listItem = md.parseListItem(line);

    if (!md.isCursorOnCheckbox(listItem, i - lineRange.start)) return;

    const newListItem = md.toggleListItemCheckbox(listItem);
    const checkboxRange = md.getListItemCheckboxRange(listItem);

    const startPos = lineRange.start + checkboxRange.start;
    const endPos = lineRange.start + checkboxRange.end;

    const newText = text.slice(0, startPos) + newListItem.checkbox + text.slice(endPos);
    props.onChange(newText);

    requestAnimationFrame(() => {
      const newCursor = startPos + newListItem.checkbox.length;
      textarea.setSelectionRange(newCursor, newCursor);
    });
  }

  const { onClick, onMouseDown } = useClickWithoutDrag(clickCb);

  function pasteCb(e: React.ClipboardEvent<HTMLTextAreaElement>) {
    const textarea = textareaRef.current!;
    const pasteData = e.clipboardData.getData('text/plain');

    // const start = textarea.selectionDirection === 'forward' ? textarea.selectionEnd : textarea.selectionStart;
    const text = textarea.value;

    const selectionStart = textarea.selectionStart;
    const selectionEnd = textarea.selectionEnd;

    const lineRange = md.getLineRangeAt(text, selectionStart);
    const line = md.getLine(text, lineRange);
    const listItem = md.parseListItem(line);

    if (!listItem.type) return;

    e.preventDefault();

    const pasteLines = pasteData.split(/\r?\n/g);
    const pasteListItems = pasteLines.map(md.parseListItem);
    pasteListItems[0].content = listItem.content + pasteListItems[0].content;

    // const linePrefix = md.stringifyListItemPrefix(listItem);

    const newLineItems: md.ListItem[] = [];
    let emptyListItem = { ...listItem, content: '' };
    for (const pasteListItem of pasteListItems) {
      emptyListItem = md.incrementListItemNumber(emptyListItem);
      newLineItems.push({
        ...emptyListItem,
        checkbox: pasteListItem.checkbox || emptyListItem.checkbox,
        content: pasteListItem.content,
      });
    }

    const newText = newLineItems.map(md.stringifyListItem).join('\n');
    replaceText(lineRange.start, selectionEnd, newText);
  }

  useLayoutEffect(() => {
    if (props.autoExpand) {
      const editor = textareaRef.current!;
      const style = window.getComputedStyle(editor);
      const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
      editor.style.height = '0'; // Shrink it first.
      editor.style.height = `${editor.scrollHeight - padding}px`;
    }
  }, [props.value, props.autoExpand]);

  return (
    <textarea
      id={props.id}
      ref={textareaRef}
      className={`editor text-input ${props.className || ''}`}
      onMouseDown={onMouseDown}
      onClick={onClick}
      onFocus={props.onFocus}
      onBlur={props.onBlur}
      onChange={changeCb}
      onKeyDown={keyDownCb}
      value={props.value}
      placeholder={props.placeholder}
      // autoFocus={props.autoFocus}
      readOnly={props.readOnly}
      // onSelect={selectCb}
      onPaste={pasteCb}
    />
  );
});


================================================
FILE: src/client/ExportPage.tsx
================================================
import React, { useCallback, useState, useEffect, useRef } from 'react';
import type * as t from '../common/types.js';
import { createNewNote } from '../common/util.js';
import * as actions from './appStoreActions.jsx';
import * as storage from './storage.js';
import { PageLayout, PageHeader, PageBody, PageAction } from './PageLayout.jsx';
import { Notes, Note } from './Notes.jsx';
import _ from 'lodash';
import exportMd from './notes/export.md';

const exportNote = createNewNote(exportMd);

export function ExportPage() {
  async function hashLinkClicked(hash: string) {
    try {
      const notes = await storage.getAllNotes();
      offerDownload('notes.json', JSON.stringify(notes, null, 2));
    } catch (error) {
      actions.gotError(error as Error);
    }
  }

  return (
    <PageLayout>
      <PageHeader title="/ export" />
      <PageBody>
        <div className="page">
          <Notes notes={[exportNote]} readonly onHashLinkClick={hashLinkClicked} />
        </div>
      </PageBody>
    </PageLayout>
  );
}

function offerDownload(filename: string, text: string) {
  var element = document.createElement('a');
  element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
  element.setAttribute('download', filename);
  element.style.display = 'none';
  document.body.appendChild(element);
  element.click();
  document.body.removeChild(element);
}


================================================
FILE: src/client/ImportPage.tsx
================================================
import React, { useState } from 'react';
import type * as t from '../common/types.js';
import { createNewNote, assert } from '../common/util.js';
import * as actions from './appStoreActions.jsx';
import { PageLayout, PageHeader, PageBody } from './PageLayout.jsx';
import { Notes } from './Notes.jsx';
import _ from 'lodash';
import log from './logger.js';
import { unzip } from 'unzipit';
import { v4 as uuid } from 'uuid';
import importMd from './notes/import.md';

const initialImportNote = createNewNote(importMd);

const importers = {
  '#keep': importKeep,
  '#apple': importApple,
  '#standard': importStandard,
  '#unforget': importUnforget,
};

type ImportKeys = keyof typeof importers;

export function ImportPage() {
  // const app = appStore.use();

  // const [file, setFile] = useState<File>();
  const [importing, setImporting] = useState(false);
  const [importType, setImportType] = useState<ImportKeys>();
  const [note, setNote] = useState(initialImportNote);

  async function importCb(e: React.ChangeEvent<HTMLInputElement>) {
    try {
      const newFile = e.target.files?.[0];
      // setFile(newFile);
      if (!newFile) return;
      setImporting(true);
      assert(importType, 'Unknown import type');
      const notes = await importers[importType](newFile, note);

      if (notes.length) {
        await actions.saveNotes(notes, { message: `Imported ${notes.length} notes`, immediateSync: true });
      } else {
        actions.showMessage('No notes were found');
      }

      window.history.replaceState(null, '', '/');
    } catch (error) {
      actions.gotError(error as Error);
    } finally {
      setImporting(false);
    }
  }

  function hashLinkClicked(hash: string) {
    setImportType(hash as ImportKeys);
    (document.querySelector('input[type="file"]') as HTMLInputElement).click();
  }

  return (
    <PageLayout>
      <PageHeader title="/ import" />
      <PageBody>
        <div className="page">
          {!importing && <Notes notes={[note]} onHashLinkClick={hashLinkClicked} onNoteChange={setNote} />}
          {!importing && (
            <input
              type="file"
              name="file"
              accept="application/zip, application/json"
              onChange={importCb}
              style={{ display: 'none' }}
            />
          )}
          {importing && <h2 className="page-message">Please wait ...</h2>}
          {/*
          <div className="-content">
            <h1>Google Keep</h1>
            <p>
              Go to{' '}
              <a target="_blank" href="https://takeout.google.com/">
                Google Takeout
              </a>
              .
            </p>
            <p>Select only Keep's data for export.</p>
            <p>Export it as a zip file.</p>
            <p className="wait-for-download">It'll be ready for download in a few minutes.</p>
            <p className="on-device">Your data will stay on your device.</p>

            <button className="import primary" onClick={importCb}>
              Import notes from zip file
            </button>
            <input type="file" name="file" accept="application/zip" onChange={e => setFile(e.target.files?.[0])} />

        </div>
        */}
        </div>
      </PageBody>
    </PageLayout>
  );
}

async function importUnforget(jsonFile: File): Promise<t.Note[]> {
  return JSON.parse(await jsonFile.text());
}

async function importKeep(zipFile: File, note: t.Note): Promise<t.Note[]> {
  const optIncludeTags = hasOption(note.text!, 'include labels as tags');

  const { entries } = await unzip(zipFile);

  const regexp = /^Takeout\/Keep\/[^\/]+\.json$/;
  const jsonEntries = Object.values(entries).filter(entry => regexp.test(entry.name));

  const notes: t.Note[] = [];
  for (const entry of jsonEntries) {
    const entryText = await entry.text();
    const json = JSON.parse(entryText);
    let errorMessage: string | undefined;
    if ((errorMessage = validateGoogleKeepJson(json))) {
      log(entryText);
      throw new Error(`Found a note with unknown format: ${errorMessage}`);
    }
    if (json.isTrashed) continue;

    const segments = [
      json.title,
      json.textContent,
      (json.listContent || [])
        .map((item: any) => (item.isChecked ? `- [x] ${item.text || ''}` : `- [ ] ${item.text || ''}`))
        .join('\n'),
      optIncludeTags && json.labels?.map((x: any) => '#' + x.name).join(' '),
    ];
    const text = segments.filter(Boolean).join('\n\n');

    notes.push({
      id: uuid(),
      text,
      creation_date: new Date(Math.floor(json.createdTimestampUsec / 1000)).toISOString(),
      modification_date: new Date(Math.floor(json.userEditedTimestampUsec / 1000)).toISOString(),
      order: Math.floor(json.createdTimestampUsec / 1000),
      not_deleted: 1,
      not_archived: json.isArchived ? 0 : 1,
      pinned: json.isPinned ? 1 : 0,
    });
  }

  return notes;
}

function validateGoogleKeepJson(json: any): string | undefined {
  if (!('createdTimestampUsec' in json)) return 'Missing createdTimestampUsec';
  if (!('userEditedTimestampUsec' in json)) return 'Missing userEditedTimestampUsec';

  // NOTE: some notes have neither listContent nor textContent. So be more lenient.
  // if (!('isTrashed' in json)) return 'Missing isTrashed';
  // if (!('isPinned' in json)) return 'Missing isPinned';
  // if (!('isArchived' in json)) return 'Missing isArchived';
  // if (!('listContent' in json) && !('textContent' in json)) return 'Missing listContent and textContent';
  // if (!('title' in json) && !('title' in json)) return 'Missing title';
}

async function importApple(zipFile: File, note: t.Note): Promise<t.Note[]> {
  const optIncludeTags = hasOption(note.text!, 'include folder names as tags');

  const { entries } = await unzip(zipFile);
  const regexp = /^.*-(\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ)\.txt$/;
  const notes: t.Note[] = [];

  for (const entry of Object.values(entries)) {
    const parts = entry.name.split('/');
    if (parts.includes('Recently Deleted')) continue;

    const match = parts.at(-1)?.match(regexp);
    if (!match) continue;

    const date = new Date(match[1]);

    let text = await entry.text();
    if (optIncludeTags) {
      const tags = parts
        .slice(1, -2)
        .map(x => '#' + x.replace(' ', '-'))
        .join(' ');
      text += '\n\n' + tags;
    }

    notes.push({
      id: uuid(),
      text,
      creation_date: date.toISOString(),
      modification_date: date.toISOString(),
      order: date.valueOf(),
      not_deleted: 1,
      not_archived: 1,
      pinned: 0,
    });
  }
  return notes;
}

async function importStandard(zipFile: File): Promise<t.Note[]> {
  const { entries } = await unzip(zipFile);
  const regexp = /^([^\/]+)\.txt$/;
  const notes: t.Note[] = [];
  const startMs = Date.now();

  for (const [i, entry] of Object.values(entries).entries()) {
    const match = entry.name.match(regexp);
    if (!match) continue;

    const entryText = await entry.text();
    const title = match[1];
    const text = title + '\n\n' + entryText;

    notes.push({
      id: uuid(),
      text,
      creation_date: new Date(startMs - i).toISOString(),
      modification_date: new Date(startMs - i).toISOString(),
      order: startMs - i * 1000,
      not_deleted: 1,
      not_archived: 1,
      pinned: 0,
    });
  }

  return notes;
}

function hasOption(text: string, label: string): boolean {
  const regexp = new RegExp('^\\s*- \\[(.)\\] ' + label + '$', 'm');
  const match = text.match(regexp);
  assert(match, `option "${label}" doesn't exist.`);
  return match[1] === 'x';
}


================================================
FILE: src/client/LoginPage.css
================================================
.login-page {
  max-width: 300px;

  .form-element {
    display: flex;
    flex-direction: column;

    & + .form-element {
      margin-top: 0.5rem;
    }
    label {
      /* width: 80px; */
    }
    input[type='text'] {
      margin-top: 0.25rem;
    }
    input[type='checkbox'] {
      vertical-align: middle;
    }

    .strength {
      font-size: 0.9em;
      margin-left: 0.5rem;
    }
  }

  .buttons {
    margin-top: 1rem;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-gap: 0.5rem;

    & button.login {
      /* max-width: 150px; */
      /* width: 100%; */
      /* margin-left: auto; */
      /* margin-right: auto; */
    }
  }

  .section {
    margin-top: 2rem;
    border-top: 1px solid #aadfef;
    padding-top: 1rem;
    & p + p {
      margin: 1rem;
    }
  }

  .welcome {
    text-align: center;
  }

  .storage-message {
    text-align: center;
    button {
      width: 100%;
    }
  }
}


================================================
FILE: src/client/LoginPage.tsx
================================================
import { useRouter } from './router.jsx';
import React, { useEffect, useState } from 'react';
import * as appStore from './appStore.js';
import * as actions from './appStoreActions.jsx';
import { PageLayout, PageHeader, PageBody } from './PageLayout.jsx';
import _ from 'lodash';

type LoginPageProps = {};

function LoginPage(props: LoginPageProps) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const valid = Boolean(username && password);
  let passwordStrength = 0;

  if (password) {
    if (password.length >= 10) passwordStrength++;
    if (/[a-z]/.test(password)) passwordStrength++;
    if (/[A-Z]/.test(password)) passwordStrength++;
    if (/[0-9]/.test(password)) passwordStrength++;
    if (/[^a-zA-Z0-9]/.test(password)) passwordStrength++;
    if (/[^0-9]/.test(password) && password.length >= 16) passwordStrength = 5;
  }

  async function loginCb() {
    if (valid) {
      await actions.login({ username, password }, { importDemoNotes: false });
    } else {
      actions.showMessage('Please enter username and password');
    }
  }
  async function signupCb() {
    if (valid) {
      if (passwordStrength < 5) {
        const answer = window.confirm(
          'A weak password enables a hacker to break the encryption of your data. Are you sure you want to continue?',
        );
        if (!answer) return;
      }
      await actions.signup({ username, password }, { importDemoNotes: true });
    } else {
      actions.showMessage('Please enter username and password');
    }
  }

  function keyDownCb(e: React.KeyboardEvent) {
    if (e.key === 'Enter' && valid) loginCb();
  }

  const app = appStore.use();
  const search = useRouter().search;

  useEffect(() => {
    if (app.user && app.user?.username !== 'demo') {
      const from = new URLSearchParams(search).get('from');
      history.replaceState(null, '', from || '/');
    }
  }, [app.user]);

  return (
    <PageLayout>
      <PageHeader compact />
      <PageBody>
        <div className="page login-page">
          <div className="form-element">
            <label htmlFor="username">Username</label>
            <input
              className="text-input small"
              type="text"
              name="username"
              required
              minLength={4}
              maxLength={50}
              onChange={e => setUsername(e.target.value)}
              onKeyDown={keyDownCb}
            />
          </div>
          <div className="form-element">
            <label htmlFor="password">
              Password
              {password && (
                <span className={`strength strength-${passwordStrength}`}> strength: {passwordStrength} / 5</span>
              )}
            </label>
            <input
              className="text-input small"
              type="password"
              name="password"
              required
              minLength={8}
              maxLength={100}
              onChange={e => setPassword(e.target.value)}
              onKeyDown={keyDownCb}
            />
          </div>
          {/*app.user?.username === 'demo' && app.notes.length > 0 && (
            <div className="form-element">
              <label>
                <input type="checkbox" onChange={e => setImportDemoNotes(e.target.checked)} checked={importDemoNotes} />{' '}
                Import {app.notes.length} {app.notes.length === 1 ? 'note' : 'notes'} from demo user (
                <Link to="/">see notes</Link>)
              </label>
            </div>
          )*/}
          <div className="buttons">
            <button className="login primary" onClick={loginCb}>
              Log in
            </button>
            <button className="signup" onClick={signupCb}>
              Sign up
            </button>
          </div>
          {/*<div className="section welcome">
            <p>Unforget is a note taking app.</p>
            <p>Notes will be encrypted on your device(s).</p>
            <p>Nobody can recover your notes if you lose your password.</p>
            </div>*/}
          {/*app.notes.length > 0 && (
            <div className="section storage-message">
              <p>
                There are existing notes on this device.
                <br />
                They'll be synced after you log in or sign up.
              </p>
              <button onClick={actions.clearStorage}>Clear local storage</button>
            </div>
            )*/}
        </div>
      </PageBody>
    </PageLayout>
  );
}

export default LoginPage;


================================================
FILE: src/client/Menu.css
================================================
.menu {
  position: absolute;
  top: 28px;
  background: #ffffff;
  /* backdrop-filter: blur(5px); */
  z-index: var(--menu-z-index);
  color: #424242;
  border-radius: 5px;
  width: 250px;
  box-shadow: 5px 5px 14px 7px #00000022;
  overflow: hidden;

  &.left {
    left: 0;
  }
  &.right {
    right: 0;
  }
  &.center {
    left: 50%;
    transform: translate(-50%, 0px);
  }

  & ul {
    margin: 0;
    padding: 0;
    list-style-type: none;

    & img {
      width: 15px;
      height: 15px;
      margin-left: auto;
    }

    & li.header {
      display: flex;
      align-items: center;
      font-size: 1.1em;
      background: var(--menu-header-background);
      /* background: #e1efff; */
      padding: 0.5rem 1rem;
      border-bottom: 1px solid #cacfd5;
    }

    & li.footer {
      display: flex;
      align-items: center;
      font-size: 0.8em;
      background: #f0f7ff;
      padding: 0.25rem 1rem;
      border-top: 1px solid #cacfd5;
    }

    & li {
      &.has-top-separator {
        border-top: 1px solid #cacfd5;
      }

      & a {
        display: flex;
        align-items: center;
        white-space: nowrap;
        padding: 0.5rem 1rem;
        transition: 0.2s background ease-in-out;
        /* font-weight: bold; */
        /* font-size: 1.1em; */
      }
      & a:hover {
        background: #fdddd2;
      }
    }
  }
}


================================================
FILE: src/client/Menu.tsx
================================================
import { useRouter } from './router.jsx';
import React, { useState, useEffect, useRef } from 'react';

export type MenuItem = {
  isHeader?: boolean;
  hasTopSeparator?: boolean;
  label: string;
  icon: string;
  to?: string;
  onClick?: () => any;
};

export type MenuProps = { menu: MenuItem[]; side: 'left' | 'right' | 'center'; onClose: () => any; trigger?: string };

export function Menu(props: MenuProps) {
  const router = useRouter();

  function menuItemClicked(e: React.MouseEvent<HTMLAnchorElement>) {
    e.preventDefault();
    e.stopPropagation();
    props.onClose();
    const item = props.menu[Number((e.target as HTMLAnchorElement).dataset.menuIndex)];
    if (item.onClick) {
      item.onClick();
    } else if (item.to && router.pathname !== item.to) {
      history.pushState(null, '', item.to);
    }
  }

  useEffect(() => {
    function callback(e: MouseEvent) {
      const target = e.target as HTMLElement | undefined;
      const clickedOnTrigger = props.trigger && target?.closest(props.trigger);
      const clickedOnMenu = target?.closest('.menu');
      if (!clickedOnTrigger && !clickedOnMenu) props.onClose();
    }
    window.addEventListener('mousedown', callback);
    return () => window.removeEventListener('mousedown', callback);
  }, [props.trigger, props.onClose]);

  return (
    <div className={`menu ${props.side}`}>
      <ul>
        {props.menu.map<React.ReactNode>((item, i) =>
          item.isHeader ? (
            <li key={i} className="header">
              {item.label}
              <img src={item.icon} />
            </li>
          ) : (
            <li key={i} className={item.hasTopSeparator ? 'has-top-separator' : ''}>
              <a href={item.to || '#'} onClick={menuItemClicked} className="reset" data-menu-index={i}>
                {item.label}
                <img src={item.icon} />
              </a>
            </li>
          ),
        )}
      </ul>
    </div>
  );
}


================================================
FILE: src/client/NotePage.css
================================================
.note-page {
  margin-bottom: 0;

  .note-container {
    flex: 1;
    display: flex;
    flex-direction: column;
    position: relative;

    .editor {
      flex: 1;
      padding: 1rem;
    }
    .footer {
      padding: 0.75rem 1rem;
      font-size: 0.6em;
      opacity: 0.6;
      display: flex;
      justify-content: space-between;
      white-space: nowrap;
    }
  }
}


================================================
FILE: src/client/NotePage.tsx
================================================
import { useRouter, RouteMatch } from './router.jsx';
import React, { useCallback, useState, useEffect, useRef } from 'react';
import type * as t from '../common/types.js';
import { isNoteNewerThan, formatDateTime } from '../common/util.js';
import * as storage from './storage.js';
import * as appStore from './appStore.js';
import * as actions from './appStoreActions.jsx';
import { Editor, EditorContext } from './Editor.jsx';
import { PageLayout, PageHeader, PageBody, PageAction } from './PageLayout.jsx';
import _ from 'lodash';
import * as icons from './icons.js';
import * as b from './cross-context-broadcast.js';
import { addSyncEventListener, removeSyncEventListener, type SyncEvent } from './sync.js';
// import log from './logger.js';

export function NotePage() {
  const app = appStore.use();
  const { match, loaderData, state: historyState } = useRouter();
  const [note, setNote] = useState(loaderData!.read() as t.Note | undefined);
  const editorRef = useRef<EditorContext | null>(null);

  // Here's a dirty hack to fix Safari hiding the fixed toolbar when we focus on the text editor.
  // Why is this still a thing? why? just why?
  // Inspired by https://www.codemzy.com/blog/sticky-fixed-header-ios-keyboard-fix
  // useEffect(() => {
  //   function setTop() {
  //     const h = document.getElementById('page-header-inner-wrapper')!;
  //     // -2 is used to prevent a small gap from appearing especially on IOS Safari.
  //     let top = Math.max(0, window.scrollY - 2);
  //     if (window.innerHeight === document.body.offsetHeight) {
  //       top = 0;
  //     }
  //     h.style.paddingTop = `${top}px`;

  //     // Could also fix it by scrolling to top, but then the cursor might go behind the soft keyboard.
  //     // window.scrollTo(0, 0);

  //     req = requestAnimationFrame(setTop);
  //   }

  //   let req = requestAnimationFrame(setTop);
  //   return () => cancelAnimationFrame(req);
  // }, []);

  // Check for changes in storage initiated externally or internally and possibly replace note.
  useEffect(() => {
    async function checkStorageAndUpdateNote() {
      const newNote = await storage.getNote(match!.params.noteId as string);
      if (newNote && isNoteNewerThan(newNote, note)) setNote(newNote);
    }

    function handleBroadcastMessage(message: t.BroadcastChannelMessage) {
      if (message.type === 'notesInStorageChanged') {
        checkStorageAndUpdateNote();
      }
    }

    function handleSyncEvent(e: SyncEvent) {
      if (e.type === 'mergedNotes') {
        checkStorageAndUpdateNote();
      }
    }

    b.addListener(handleBroadcastMessage); // External changes.
    addSyncEventListener(handleSyncEvent); // Internal changes.
    return () => {
      removeSyncEventListener(handleSyncEvent);
      b.removeListener(handleBroadcastMessage);
    };
  }, [note, match!.params.noteId]);

  // Keyboard shortcuts.
  useEffect(() => {
    function callback(e: KeyboardEvent) {
      function handle(handler: () => any) {
        e.preventDefault();
        e.stopPropagation();
        handler();
      }
      const ctrlOrMeta = e.ctrlKey || e.metaKey;

      if (e.key === 'Enter' && ctrlOrMeta) {
        handle(goHome);
      } else if (e.key === 'Escape') {
        if (ctrlOrMeta) {
          handle(toggleArchiveCb);
        } else {
          handle(goHome);
        }
      } else if (e.key === 'Delete' && e.shiftKey && ctrlOrMeta) {
        handle(deleteCb);
      } else if (e.key === '.' && ctrlOrMeta) {
        handle(cycleListStyleCb);
      } else if (e.key === 'p' && ctrlOrMeta) {
        handle(togglePinned);
      }
    }

    window.addEventListener('keydown', callback);
    return () => window.removeEventListener('keydown', callback);
  });

  const goHome = useCallback(() => {
    if (historyState.index > 0) {
      history.back();
    } else {
      history.pushState(null, '', '/');
    }
  }, []);

  const textChangeCb = useCallback(
    (text: string) => {
      const newNote: t.Note = { ...note!, text, modification_date: new Date().toISOString() };
      setNote(newNote);
      actions.saveNote(newNote);
    },
    [note],
  );

  const toggleArchiveCb = useCallback(() => {
    const newNote: t.Note = {
      ...note!,
      modification_date: new Date().toISOString(),
      not_archived: note!.not_archived ? 0 : 1,
    };
    actions
      .saveNote(newNote, { message: newNote.not_archived ? 'Unarchived' : 'Archived', immediateSync: true })
      .then(() => {
        setNote(newNote);
        if (!newNote.not_archived) goHome();
      });
  }, [goHome, note]);

  const deleteCb = useCallback(() => {
    if (confirm('Are you sure you want to delete this note?')) {
      const newNote: t.Note = { ...note!, modification_date: new Date().toISOString(), text: null, not_deleted: 0 };
      actions.saveNote(newNote, { message: 'Deleted', immediateSync: true }).then(() => {
        setNote(newNote);
        goHome();
      });
    }
  }, [goHome, note]);

  const togglePinned = useCallback(() => {
    const newNote = { ...note!, modification_date: new Date().toISOString(), pinned: note!.pinned ? 0 : 1 };
    actions
      .saveNote(newNote, { message: note!.pinned ? 'Unpinned' : 'Pinned', immediateSync: true })
      .then(() => setNote(newNote));
  }, [note]);

  // Save note on beforeunload event.
  useEffect(() => {
    function callback(e: BeforeUnloadEvent) {
      if (storage.isSavingNote()) e.preventDefault();
    }
    window.addEventListener('beforeunload', callback);
    return () => window.removeEventListener('beforeunload', callback);
  }, []);

  // const insertMenu = createInsertMenu(() => editorRef.current!);

  const cycleListStyleCb = useCallback(() => {
    editorRef.current!.cycleListStyle();
  }, []);

  const pageActions = note && [
    <PageAction icon={icons.trashWhite} onClick={deleteCb} title="Delete (Ctrl+Shift+Delete or Cmd+Shift+Delete)" />,
    <PageAction
      icon={note.not_archived ? icons.archiveEmptyWhite : icons.archiveFilledWhite}
      onClick={toggleArchiveCb}
      title="Archive (Ctrl+Esc or Cmd+Esc)"
    />,
    <PageAction
      icon={note.pinned ? icons.pinFilledWhite : icons.pinEmptyWhite}
      onClick={togglePinned}
      title={note.pinned ? 'Unpin (Ctrl+p or Cmd+p)' : 'Pin (Ctrl+p or Cmd+p)'}
    />,
    <PageAction icon={icons.cycleListWhite} onClick={cycleListStyleCb} title="Cycle list style (Ctrl+. or Cmd+.)" />,
    <PageAction icon={icons.checkWhite} onClick={goHome} title="Done (Esc or Ctrl+Enter or Cmd+Enter)" />,
  ];

  return (
    <PageLayout>
      <PageHeader actions={pageActions} />
      <PageBody>
        <div className="page note-page">
          {!note && (app.syncing || app.updatingNotes) && <h2 className="page-message">...</h2>}
          {!note && !(app.syncing || app.updatingNotes) && <h2 className="page-message">Not found</h2>}
          {note && (
            <div className="note-container">
              <Editor
                ref={editorRef}
                className="text-input"
                placeholder="What's on your mind?"
                value={note.text ?? ''}
                onChange={textChangeCb}
              />
              <div className="footer">
                <span>Created on {formatDateTime(new Date(note.creation_date))}</span>
                {note.creation_date !== note.modification_date && (
                  <span>Updated on {formatDateTime(new Date(note.modification_date))}</span>
                )}
              </div>
            </div>
          )}
        </div>
      </PageBody>
    </PageLayout>
  );
}

export async function notePageLoader({ params }: RouteMatch): Promise<t.Note | undefined> {
  if (appStore.get().user) {
    return await storage.getNote(params.noteId as string);
  }
}


================================================
FILE: src/client/Notes.css
================================================
.notes {
  border: 1px solid var(--box-border-color);
  border-radius: var(--box-border-radius);
  overflow: hidden;

  @media (max-width: 800px) {
    border-radius: 0;
    border-left: 0;
    border-right: 0;
  }

  /* Show selection circles if a selectable note is hovered. */
  &.selectable .note:hover {
    @media (pointer: fine) {
      .select {
        visibility: visible;
      }
      & img.pin {
        /* visibility: hidden; */
      }
    }
  }

  /* Show selection circles if selection mode is on. */
  &.has-selection .note {
    .select {
      visibility: visible;
    }
    & img.pin {
      /* visibility: hidden; */
    }
  }

  /* Resize and move the pin when the selection circle is shown */
  &.selectable .note:hover img.pin,
  &.has-selection .note img.pin {
    top: -1px;
    right: -2px;
    width: 15px;
  }

  .note {
    padding: 0.2rem 1rem; /* The vertical padding accounts for the line-height of paragraphs and headings */
    transition: 0.2s background ease-in-out;
    font-family: inherit;
    /* word-break: break-word; */
    /* word-wrap: break-word; */
    /* line-height: var(--note-line-height); */
    /* margin: 0; */
    position: relative;
    display: flex; /* This is to disable margin collapse */
    flex-direction: column;
    min-height: 2rem;

    &.clickable {
      cursor: pointer;
      &:hover {
        background: #d0ebf5;
      }
    }

    &.selected {
      .select {
        visibility: visible;
      }
      /* & img.pin { */
      /*   visibility: hidden; */
      /* } */
    }

    &.pinned {
      /* border-left: 3px solid var(--box-border-color); */
      /* &:first-child { */
      /*   border-radius: var(--box-border-radius) var(--box-border-radius) 0 0; */
      /* } */
      /* &:last-child { */
      /*   border-radius: 0 0 var(--box-border-radius) var(--box-border-radius); */
      /* } */
    }

    & + .note {
      border-top: 1px solid var(--box-border-color);
    }

    & .select {
      --select-padding: 6.885px;
      position: absolute;
      top: 0;
      right: 0;
      /* width: calc(30px + var(--select-padding) * 2); */
      /* height: calc(30px + var(--select-padding) * 2); */
      padding: var(--select-padding);
      visibility: hidden;

      &:hover,
      &:focus,
      &:focus-visible,
      &:active {
        outline: none;
        border: none;
        background: none;

        .circle {
          box-shadow: 0px 0px 10px 0px #0000002e;
          transition: 0.1s all ease-in-out;
        }
      }

      &.selected .circle {
        border: 1px solid #888;
        box-shadow: none;
      }

      &:not(.selected) .circle img {
        display: none;
      }

      .circle {
        width: 30px;
        height: 30px;
        border-radius: 50%;
        background: #fff;
        border: 1px solid #ccc;
        position: relative;
        /* box-shadow: 0 0 3px 0px #ccc inset; */
        display: flex;
        align-items: center;
        justify-content: center;

        & img {
          margin-top: 3px;
        }
      }
    }

    & img.pin {
      position: absolute;
      top: 2px;
      right: 2px;
      width: 20px;
      opacity: 0.5;
      transition: all 0.2s ease-in-out;
    }

    & .empty {
      opacity: 0.5;
      font-style: italic;
    }

    & hr {
      margin: 1rem 0;
      border-color: var(--box-border-color);
      border-style: dashed;
      opacity: 0.5;
    }

    & table {
      border: 1px dashed var(--box-border-color);
      border-collapse: collapse;
    }

    & th,
    td {
      padding: 0.25rem 0.5rem;
      border: 1px dashed var(--box-border-color);
    }

    & pre {
      background: #00000005;
      padding: 0.5rem;
      white-space: pre-wrap;
    }
    & blockquote {
      background: #00000005;
      padding: 0 0.5rem;
    }

    & table {
      background: #00000005;
    }

    & h1,
    h2,
    h3,
    h4,
    h5,
    h6 {
      &:not(:first-child) {
        margin-top: 1rem;
      }
    }

    & h1 {
      font-size: 1.1rem;
    }

    & h2 {
      font-size: 1rem;
    }

    & h3,
    h4,
    h5,
    h6 {
      font-size: 1rem;
    }

    & ul {
      padding-inline-start: calc(var(--checkbox-size) + var(--single-space-size) + var(--checkbox-right-margin));

      & > li.task-list-item {
        list-style-type: none;
        /* &::marker { */
        /*   content: ''; */
        /* } */

        & > input[type='checkbox'],
        & > p > input[type='checkbox'] {
          margin-left: calc(-1 * (var(--checkbox-size) + var(--single-space-size) + var(--checkbox-right-margin)));
        }
      }

      & > li:not(.task-list-item) {
        position: relative;
        list-style-type: none;
        /* &::marker { */
        /*   content: ''; */
        /* } */

        &:empty {
          height: 1.5rem;
        }

        &:before {
          content: '';
          /* float: left; */
          /* display: list-item; */
          /* list-style-type: circle; */
          /* list-style-position: inside; */
          /* width: 20px; */
          /* font-size: 1.5rem; */
          /* line-height: 0.8; */
          /* vertical-align: middle; */
          /* width: 1px; */
          left: calc(
            -1 * (var(--checkbox-size) / 2 + var(--single-space-size) + var(--checkbox-right-margin) +
                  var(--bulletpoint-size) / 2 + 1px)
          );
          width: var(--bulletpoint-size);
          height: var(--bulletpoint-size);
          border-radius: 50%;
          border: 2px solid #444;
          position: absolute;
          top: 6px;
        }
      }
    }

    & ol {
      padding-inline-start: calc(var(--checkbox-size) + var(--single-space-size));

      & > li.task-list-item {
        padding-left: calc(var(--checkbox-size) + var(--single-space-size) + var(--checkbox-right-margin) + 6px);

        & > input[type='checkbox'],
        & > p > input[type='checkbox'] {
          margin-left: calc(
            var(--single-space-size) + 2px - var(--checkbox-size) - var(--single-space-size) -
              var(--checkbox-right-margin) - 6px
          );
        }
      }
    }

    /* & ol { */
    /*   padding-inline-start: calc(var(--checkbox-size) + var(--checkbox-first-margin)); */
    /* } */

    /*
    & ul {
      padding-inline-start: var(--checkbox-size-with-margin);
    }

    & ol {
      padding-inline-start: calc(var(--checkbox-size) + var(--checkbox-first-margin));
    }

    & ul > li.task-list-item {
      text-indent: calc(-1 * var(--checkbox-size-with-margin));

      &::marker {
        content: '';
      }
    }

    & ol > li.task-list-item > input[type='checkbox'] {
      margin-left: var(--checkbox-second-margin);
    }
    */

    & input[type='checkbox'] {
      border: 1px solid #aadfef;
      border-radius: 5px;
      outline: none;
      padding: 0.5rem 0rem;
      width: var(--checkbox-size);
      height: var(--checkbox-size);
      vertical-align: middle;
      margin: 0;
      margin-top: -2px;
      accent-color: var(--checkbox-accent);
      margin-right: var(--checkbox-right-margin);
    }
  }
}


================================================
FILE: src/client/Notes.tsx
================================================
import React, { memo, useState } from 'react';
import type * as t from '../common/types.js';
import { assert } from '../common/util.js';
import * as md from '../common/mdFns.js';
// import * as actions from './appStoreActions.jsx';
import { useClickWithoutDrag } from './hooks.jsx';
import _ from 'lodash';
import * as icons from './icons.js';
import { toHtml } from 'hast-util-to-html';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { toHast } from 'mdast-util-to-hast';
import { gfm } from 'micromark-extension-gfm';
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm';
import { visit } from 'unist-util-visit';
import { visitParents } from 'unist-util-visit-parents';
import { newlineToBreak } from 'mdast-util-newline-to-break';

export function Notes(props: {
  notes: t.Note[];
  readonly?: boolean;
  onHashLinkClick?: (hash: string) => any;
  onNoteChange?: (note: t.Note) => any;
  onNoteClick?: (note: t.Note) => any;
  onToggleNoteSelection?: (note: t.Note) => any;
  hiddenNoteId?: string;
  hideContentAfterBreak?: boolean;
  noteSelection?: string[];
  selectable?: boolean;
}) {
  const notes = props.notes.filter(n => n.id !== props.hiddenNoteId);
  return (
    <div className={`notes ${props.noteSelection ? 'has-selection' : ''} ${props.selectable ? 'selectable' : ''}`}>
      {notes.map(note => (
        <Note
          key={note.id}
          note={note}
          readonly={props.readonly}
          onHashLinkClick={props.onHashLinkClick}
          onNoteChange={props.onNoteChange}
          onNoteClick={props.onNoteClick}
          onToggleNoteSelection={props.onToggleNoteSelection}
          hideContentAfterBreak={props.hideContentAfterBreak}
          selected={props.noteSelection?.includes(note.id)}
        />
      ))}
    </div>
  );
}

export const Note = memo(function Note(props: {
  note: t.Note;
  readonly?: boolean;
  onHashLinkClick?: (hash: string) => any;
  onNoteChange?: (note: t.Note) => any;
  onNoteClick?: (note: t.Note) => any;
  onToggleNoteSelection?: (note: t.Note) => any;
  hideContentAfterBreak?: boolean;
  selected?: boolean;
}) {
  // Do not modify the text here because we want the position of each element in mdast and hast to match
  // exactly the original text.
  const text = props.note.text;

  const [expanded, setExpanded] = useState(false);

  function toggleExpanded(e: React.MouseEvent) {
    e.preventDefault();
    e.stopPropagation();
    setExpanded(!expanded);
    if (expanded) {
      setTimeout(() => {
        const elem = document.getElementById(props.note.id);
        if (elem) window.scrollTo({ top: elem.getBoundingClientRect().top + window.scrollY - 50, behavior: 'smooth' });
      }, 0);
    }
  }

  function clickCb(e: React.MouseEvent) {
    // history.pushState(null, '', `/n/${props.note.id}`);
    const elem = e.target as HTMLElement;
    const link = elem.closest('a');
    const input = elem.closest('input');
    const li = elem.closest('li');
    if (input && li && !props.readonly) {
      e.preventDefault();
      e.stopPropagation();

      const [start, end] = [Number(li.dataset.posStart), Number(li.dataset.posEnd)];
      if (!Number.isFinite(start) || !Number.isFinite(end)) {
        console.error(`Got unknown start or end position for li: ${start}, ${end}`);
        return;
      }

      // console.log('checkbox at li:', start, end);
      // console.log('text:', `<START>${text!.substring(start, end)}<END>`);

      const liText = text!.substring(start, end);
      const ulCheckboxRegExp = /^(\s*[\*+-]\s*\[)([xX ])(\].*)$/m;
      const olCheckboxRegExp = /^(\s*\d+[\.\)]\s*\[)([xX ])(\].*)$/m;
      const match = liText.match(ulCheckboxRegExp) ?? liText.match(olCheckboxRegExp);
      if (!match) {
        console.error(`LiText did not match checkbox regexp: `, liText);
        return;
      }
      const newLi = match[1] + (match[2] === ' ' ? 'x' : ' ') + match[3];

      const newText = md.insertText(text!, newLi, { start, end: start + match[0].length });
      const newNote: t.Note = { ...props.note, text: newText, modification_date: new Date().toISOString() };
      props.onNoteChange?.(newNote);
    } else if (link) {
      const baseURL = new URL(document.baseURI);
      const targetURL = new URL(link.href, document.baseURI);
      const isRelative = baseURL.origin === targetURL.origin;

      if (isRelative) {
        e.preventDefault();
        e.stopPropagation();
        if (baseURL.pathname === targetURL.pathname && baseURL.hash !== targetURL.hash) {
          props.onHashLinkClick?.(targetURL.hash);
        } else {
          history.pushState(null, '', link.href);
        }
      } else {
        e.stopPropagation();
      }
    } else {
      props.onNoteClick?.(props.note);
    }
  }

  const { onClick, onMouseDown } = useClickWithoutDrag(clickCb);

  function selectCb(e: React.MouseEvent) {
    e.preventDefault();
    e.stopPropagation();
    props.onToggleNoteSelection?.(props.note);
  }

  // function inputClickCb(e: React.MouseEvent) {
  //   e.preventDefault();
  //   e.stopPropagation();
  // }

  const mdast = fromMarkdown(text ?? '', {
    extensions: [gfm()],
    mdastExtensions: [gfmFromMarkdown()],
  });
  newlineToBreak(mdast);
  // console.log('mdast', mdast);
  assert(mdast.type === 'root', 'hast does not have root');
  const noteIsEmpty = mdast.children.length === 0;

  // Turn the first line into a heading if it's not already a heading and it is followed by two new lines
  {
    const first = mdast.children[0];
    if (first?.type === 'paragraph' && text?.match(/^[^\r\n]+\r?\n\r?\n/g)) {
      mdast.children[0] = { type: 'heading', depth: 1, position: first.position, children: first.children };
    }
  }

  // Remove everything after thematicBreak
  const breakMdNodeIndex = mdast.children.findIndex(node => node.type === 'thematicBreak');
  if (props.hideContentAfterBreak && !expanded && breakMdNodeIndex !== -1) {
    mdast.children.splice(breakMdNodeIndex);
  }

  const hast = toHast(mdast);
  // console.log(hast);

  const baseURL = new URL(document.baseURI);
  visit(hast, 'element', function (node) {
    // Enable input nodes.
    if (node.tagName === 'input') {
      node.properties['disabled'] = Boolean(props.readonly);
    }

    // Set external links' target to '_blank'.
    if (node.tagName === 'a' && typeof node.properties['href'] === 'string') {
      const targetURL = new URL(node.properties['href'], document.baseURI);
      if (baseURL.origin !== targetURL.origin) {
        node.properties['target'] = '_blank';
      }
    }

    // Set start and end position of all elements.
    node.properties['data-pos-start'] = node.position?.start.offset;
    node.properties['data-pos-end'] = node?.position?.end.offset;
  });

  const html = toHtml(hast);

  return (
    <div
      id={props.note.id}
      className={`note ${props.onNoteClick ? 'clickable' : ''} ${props.selected ? 'selected' : ''} ${
        props.note.pinned ? 'pinned' : ''
      }`}
      onMouseDown={onMouseDown}
      onClick={onClick}
    >
      {Boolean(props.note.pinned) && <img className="pin" src={icons.pinFilled} />}
      {noteIsEmpty ? (
        <div>
          <h2 className="empty">Empty note</h2>
        </div>
      ) : (
        <div dangerouslySetInnerHTML={{ __html: html }} />
      )}
      {props.hideContentAfterBreak && breakMdNodeIndex >= 0 && (
        <p>
          <a href="#toggle-expand" onClick={toggleExpanded}>
            {expanded ? 'show less' : 'show more'}
          </a>
        </p>
      )}
      <div className={`select ${props.selected ? 'selected' : ''}`} tabIndex={0} onClick={selectCb}>
        <div className="circle">
          <img src={icons.check} />
        </div>
      </div>
    </div>
  );
});


================================================
FILE: src/client/NotesPage.css
================================================
.notes-page {
  .new-note-container {
    display: flex;
    flex-direction: column;
    transition: 0.15s transform ease-out;

    &.below-second-row {
      transform: translate3d(
        0,
        calc((var(--margin-after-page-header) * 2 - var(--page-header-second-row-height)) / 2),
        0
      );
    }

    &.sticky {
      position: sticky;
      top: var(--page-header-height);
      z-index: var(--sticky-z-index);
      overflow: visible;

      &.below-second-row {
        transform: none;
        top: calc(var(--page-header-height) + var(--page-header-second-row-height));
        transition: none;
      }

      .editor {
        /* more padding to clear the top where messages are shown. */
        /* min-height: 100px; */
        box-shadow: 5px 5px 14px 7px #00000022;
        border-top-left-radius: 0;
        border-top-right-radius: 0;
        max-height: 80vh;

        /* NOTE Chaning the size of the editor causes jumps on scroll position restoration during page transitions */
        /* padding-top: 1.2rem; */
        /* padding-bottom: 1.2rem; */
      }

      /* IOS Safari doesn't show the shadow if it's on .editor */
      @media (max-width: 800px) {
        box-shadow: 5px 5px 14px 7px #00000022;
        .editor {
          box-shadow: none;
        }
      }
    }

    &.invisible {
      opacity: 0;
      pointer-events: none;
    }

    &:not(.sticky) .editor:focus {
      outline: 2px solid #aadfef;
    }

    .editor {
      padding-top: 1.5rem;
      padding-bottom: 1.5rem;
    }
  }

  .notes {
    margin-top: var(--margin-after-page-header);
  }

  & button.load-more {
    margin-top: 2rem;
    padding-left: 2rem;
    padding-right: 2rem;
  }
}


================================================
FILE: src/client/NotesPage.tsx
================================================
import { RouteMatch } from './router.jsx';
import React, { useState, useEffect, useRef, memo } from 'react';
import { useStoreAndRestoreScrollY } from './hooks.js';
import type * as t from '../common/types.js';
import * as cutil from '../common/util.js';
import * as storage from './storage.js';
import * as appStore from './appStore.js';
import * as actions from './appStoreActions.jsx';
import log from './logger.js';
import { Editor, EditorContext } from './Editor.jsx';
import { PageLayout, PageHeader, PageBody, PageAction, type PageHeaderSecondRowProps } from './PageLayout.jsx';
import _ from 'lodash';
import * as icons from './icons.js';
import { Notes } from './Notes.jsx';
import * as b from './cross-context-broadcast.js';
import { addSyncEventListener, removeSyncEventListener, type SyncEvent } from './sync.js';
// import log from './logger.js';

type NotesPageProps = {};

export function NotesPage(_props: NotesPageProps) {
  const app = appStore.use();
  const [newNote, setNewNote] = useState<t.Note>();
  // const [newNoteText, setNewNoteText] = useState('');
  // const [newNotePinned, setNewNotePinned] = useState(false);
  const [editorOpen, setEditorOpen] = useState(false);
  const [editorFocused, setEditorFocused] = useState(false);
  const [stickyEditor, setStickyEditor] = useState(false);
  const editorRef = useRef<EditorContext | null>(null);
  useStoreAndRestoreScrollY();

  // Check for changes in storage initiated externally or internally and update the notes.
  useEffect(() => {
    function handleBroadcastMessage(message: t.BroadcastChannelMessage) {
      if (message.type === 'notesInStorageChanged') {
        actions.updateNotes();
      }
    }

    function handleSyncEvent(e: SyncEvent) {
      if (e.type === 'mergedNotes') {
        actions.updateNotes();
      }
    }

    b.addListener(handleBroadcastMessage); // External changes.
    addSyncEventListener(handleSyncEvent); // Internal changes.
    return () => {
      removeSyncEventListener(handleSyncEvent);
      b.removeListener(handleBroadcastMessage);
    };
  }, []);

  // Keyboard shortcuts.
  useEffect(() => {
    function callback(e: KeyboardEvent) {
      function handle(handler: () => any) {
        e.preventDefault();
        e.stopPropagation();
        handler();
      }
      const ctrlOrMeta = e.ctrlKey || e.metaKey;

      if (e.key === 'Enter' && ctrlOrMeta) {
        handle(confirmNewNoteCb);
      } else if (e.key === 'Escape' && !ctrlOrMeta) {
        if (editorOpen) {
          handle(confirmNewNoteCb);
        } else if (app.search !== undefined) {
          handle(toggleNoteSearchCb);
        } else if (app.noteSelection) {
          handle(toggleNoteSelectionMode);
        }
      } else if (e.key === 'Delete' && e.shiftKey && ctrlOrMeta) {
        handle(cancelNewNoteCb);
      } else if (e.key === '.' && ctrlOrMeta) {
        handle(cycleListStyleCb);
      } else if (e.key === 'p' && ctrlOrMeta) {
        handle(togglePinned);
      } else if (e.key === 'ArrowUp' && e.shiftKey && ctrlOrMeta) {
        handle(actions.moveNoteSelectionToTop);
      } else if (e.key === 'ArrowDown' && e.shiftKey && ctrlOrMeta) {
        handle(actions.moveNoteSelectionToBottom);
      } else if (e.key === 'ArrowUp' && ctrlOrMeta) {
        handle(actions.moveNoteSelectionUp);
      } else if (e.key === 'ArrowDown' && ctrlOrMeta) {
        handle(actions.moveNoteSelectionDown);
      }

      // Ignore the following shortcuts if input or textarea is focused
      // or if ctrl or meta key is pressed
      if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName ?? '')) return;
      if (ctrlOrMeta) return;

      if (e.key === '/') {
        if (app.search !== undefined) {
          handle(() => document.getElementById('search-input')?.focus());
        } else {
          handle(toggleNoteSearchCb);
        }
      } else if (e.key === 'p') {
        handle(toggleHidePinnedNotes);
      } else if (e.key === 'n') {
        if (editorOpen) {
          handle(() => editorRef.current?.textareaRef.current?.focus());
        } else {
          handle(startNewNoteCb);
        }
      } else if (e.key === 's') {
        handle(toggleNoteSelectionMode);
      } else if (e.key === 'A' && app.showArchive) {
        handle(unarchiveNoteSelection);
      } else if (e.key === 'a' && !app.showArchive) {
        handle(archiveNoteSelection);
      }
    }

    window.addEventListener('keydown', callback);
    return () => window.removeEventListener('keydown', callback);
  });

  function archiveNoteSelection() {
    const count = app.noteSelection?.length ?? 0;
    if (count > 0 && confirm(`Are you sure you want to archive ${count} note(s)?`)) {
      actions.archiveNoteSelection();
    }
  }

  function unarchiveNoteSelection() {
    const count = app.noteSelection?.length ?? 0;
    if (count > 0 && confirm(`Are you sure you want to unarchive ${count} note(s)?`)) {
      actions.unarchiveNoteSelection();
    }
  }

  function saveNewNote(changes: { text?: string | null; pinned?: number; not_deleted?: number }) {
    let savedNote = {
      ...(newNote ?? cutil.createNewNote('')),
      ...changes,
      modification_date: new Date().toISOString(),
    };
    setNewNote(savedNote);
    actions.saveNote(savedNote);
  }

  function deleteNewNote() {
    if (newNote) {
      appStore.update(app => {
        app.notes = app.notes.filter(n => n.id !== newNote.id);
      });
      saveNewNote({ text: null, not_deleted: 0 });
    }
  }

  function confirmNewNoteCb() {
    if (!newNote?.text?.trim()) {
      cancelNewNoteCb();
      return;
    }
    actions.showMessage('Note added', { type: 'info' });
    editorRef.current!.focus();
    setNewNote(undefined);
    actions.updateNotes();
  }

  async function cancelNewNoteCb() {
    if (!newNote || confirm('Are you sure you want to delete this note?')) {
      deleteNewNote();
      setNewNote(undefined);
      setEditorOpen(false);
      actions.updateNotes();
      (document.activeElement as HTMLElement | undefined)?.blur();
    }
  }

  // async function askUserToCancelNewNoteCb() {
  //   if (!newNote?.text?.trim() || confirm('Are you sure you want to delete the new note?')) {
  //     cancelNewNoteCb();
  //   }
  // }

  function newNoteTextChanged(text: string) {
    saveNewNote({ text });
  }

  // Set editor's stickiness on mount and on scroll.
  useEffect(() => {
    function scrolled() {
      setStickyEditor(window.scrollY > 64);
      reduceNotePagesDebounced();
    }

    scrolled();
    window.addEventListener('scroll', scrolled);
    return () => window.removeEventListener('scroll', scrolled);
  }, []);

  function editorFocusCb() {
    setEditorOpen(true);
    setEditorFocused(true);
  }

  function editorBlurCb() {
    setEditorFocused(false);
  }

  // Cancel new note if editor is empty and has lost focus.
  useEffect(() => {
    let timeout: any;
    if (editorOpen && !editorFocused && !newNote?.text) {
      timeout = setTimeout(() => cancelNewNoteCb(), 300);
    }
    return () => clearTimeout(timeout);
  }, [editorOpen, newNote, editorFocused, cancelNewNoteCb]);

  function togglePinned() {
    editorRef.current!.focus();
    saveNewNote({ pinned: newNote?.pinned ? 0 : 1 });
  }

  function toggleHidePinnedNotes() {
    const value = !app.hidePinnedNotes;
    storage.setSetting(value, 'hidePinnedNotes');
    appStore.update(app => {
      app.hidePinnedNotes = value;
    });
    actions.updateNotes();
    actions.showMessage(value ? 'Hiding pinned notes' : 'Showing pinned notes');
  }

  function loadMore() {
    appStore.update(app => {
      app.notePages++;
    });
    actions.updateNotes();
  }

  function toggleNoteSearchCb() {
    appStore.update(app => {
      if (app.search === undefined) {
        app.search = '';
        app.noteSelection ??= [];
      } else {
        app.search = undefined;
        if (!app.noteSelection?.length) app.noteSelection = undefined;
      }
    });
    actions.updateNotes();
  }

  function searchChangeCb(e: React.ChangeEvent<HTMLInputElement>) {
    appStore.update(app => {
      app.search = e.target.value;
    });
    actions.updateNotesDebounced();
  }

  // function searchKeyDownCb(e: React.KeyboardEvent) {
  //   if (e.key === 'Escape') {
  //     e.preventDefault();
  //     e.stopPropagation();
  //     toggleNoteSearchCb();
  //   }
  // }

  function cycleListStyleCb() {
    editorRef.current!.cycleListStyle();
  }

  function startNewNoteCb() {
    setEditorOpen(true);
    editorRef.current!.focus();
  }

  function toggleNoteSelectionMode() {
    appStore.update(app => {
      app.noteSelection = app.noteSelection ? undefined : [];
    });
  }

  const pageActions: React.ReactNode[] = [];
  if (editorOpen) {
    pageActions.push(
      <PageAction
        icon={icons.trashWhite}
        onClick={cancelNewNoteCb}
        title="Delete (Ctrl+Shift+Delete or Cmd+Shift+Delete)"
      />,
      <PageAction
        icon={newNote?.pinned ? icons.pinFilledWhite : icons.pinEmptyWhite}
        onClick={togglePinned}
        title={newNote?.pinned ? 'Unpin (Ctrl+p or Cmd+p)' : 'Pin (Ctrl+p or Cmd+p)'}
      />,
      <PageAction icon={icons.cycleListWhite} onClick={cycleListStyleCb} title="Cycle list style (Ctrl+. or Cmd+.)" />,
      <PageAction icon={icons.checkWhite} onClick={confirmNewNoteCb} title="Done (Esc or Ctrl+Enter or Cmd+Enter)" />,
    );
  } else if (app.search === undefined) {
    if (app.noteSelection) {
      pageActions.push(
        <PageAction
          icon={icons.circleDeselectWhite}
          onClick={toggleNoteSelectionMode}
          title={'Clear selection (s or Esc)'}
        />,
      );
    } else {
      pageActions.push(
        <PageAction icon={icons.circleSelectWhite} onClick={toggleNoteSelectionMode} title={'Select (s)'} />,
      );
    }
    pageActions.push(
      <PageAction icon={icons.searchWhite} onClick={toggleNoteSearchCb} title="Search (/)" />,
      <PageAction
        icon={app.hidePinnedNotes ? icons.hidePinnedWhite2 : icons.showPinnedWhite}
        onClick={toggleHidePinnedNotes}
        title={app.hidePinnedNotes ? 'Show pinned notes (p)' : 'Hide pinned notes (p)'}
      />,
      <PageAction icon={icons.addWhite} onClick={startNewNoteCb} title="New note (n)" />,
    );
  } else {
    pageActions.push(
      <input
        id="search-input"
        placeholder={app.showArchive ? 'Search archive ...' : 'Search ...'}
        className="search action"
        value={app.search}
        onChange={searchChangeCb}
        // onKeyDown={searchKeyDownCb}
        autoFocus
      />,
      <PageAction
        icon={app.hidePinnedNotes ? icons.hidePinnedWhite2 : icons.showPinnedWhite}
        onClick={toggleHidePinnedNotes}
        title={app.hidePinnedNotes ? 'Show pinned notes (p)' : 'Hide pinned notes (p)'}
      />,
      <PageAction
        className="close-search"
        icon={icons.xWhite}
        onClick={toggleNoteSearchCb}
        title="Close search (Esc)"
      />,
    );
  }

  let secondRow: PageHeaderSecondRowProps | undefined;
  if (app.noteSelection) {
    // const allPinned = app.notes.every(note => note.pinned);
    const allArchived = app.notes.every(note => !note.not_archived);

    secondRow = {
      title:
        app.noteSelection.length === 0
          ? 'Select notes'
          : app.noteSelection.length === 1
            ? '1 selected'
            : `${app.noteSelection.length} selected`,
      actions: [
        allArchived ? (
          <PageAction
            icon={icons.archiveFilledWhite}
            onClick={unarchiveNoteSelection}
            title="Unarchive selection (Shift+a)"
          />
        ) : (
          <PageAction icon={icons.archiveEmptyWhite} onClick={archiveNoteSelection} title="Archive selection (a)" />
        ),
        <PageAction
          icon={icons.chevronDownDoubleWhite}
          onClick={actions.moveNoteSelectionToBottom}
          title="Move selection to the bottom (Ctrl+Shift+Down or Cmd+Shift+Down)"
        />,
        <PageAction
          icon={icons.chevronUpDoubleWhite}
          onClick={actions.moveNoteSelectionToTop}
          title="Move selection to the top (Ctrl+Shift+Up or Cmd+Shift+Up)"
        />,
        <PageAction
          icon={icons.chevronDownWhite}
          onClick={actions.moveNoteSelectionDown}
          title="Move selection down (Ctrl+Down or Cmd+Down)"
        />,
        <PageAction
          icon={icons.chevronUpWhite}
          onClick={actions.moveNoteSelectionUp}
          title="Move selection up (Ctrl+Up or Cmd+Up)"
        />,
      ],
    };
  }

  return (
    <PageLayout>
      <PageHeader
        actions={pageActions}
        title={app.showArchive ? '/ archive' : undefined}
        hasSearch={app.search !== undefined}
        secondRow={secondRow}
      />
      <PageBody>
        <div className="page notes-page">
          <div
            className={`new-note-container ${stickyEditor ? 'sticky' : ''} ${
              stickyEditor && !editorOpen ? 'invisible' : ''
            } ${secondRow ? 'below-second-row' : ''}`}
          >
            <Editor
              ref={editorRef}
              className="text-input"
              placeholder="What's on your mind?"
              value={newNote?.text ?? ''}
              onChange={newNoteTextChanged}
              autoExpand
              onFocus={editorFocusCb}
              onBlur={editorBlurCb}
            />
          </div>
          {app.notes.length > 0 && <NotesFromApp hiddenNoteId={newNote?.id} />}
          {!app.notes.length && (app.syncing || app.updatingNotes) && <h2 className="page-message">...</h2>}
          {/*!app.notes.length && !(app.syncing || app.updatingNotes) && <h2 className="page-message">No notes found</h2>*/}
          {!app.allNotePagesLoaded && (
            <button className="load-more primary button-row" onClick={loadMore}>
              Load more
            </button>
          )}
        </div>
      </PageBody>
    </PageLayout>
  );
}

const NotesFromApp = memo(function NotesFromApp(props: { hiddenNoteId?: string }) {
  const app = appStore.use();
  return (
    <Notes
      notes={app.notes}
      hiddenNoteId={props.hiddenNoteId}
      onNoteChange={actions.saveNoteAndQuickUpdateNotes}
      onNoteClick={goToNote}
      onToggleNoteSelection={actions.toggleNoteSelection}
      noteSelection={app.noteSelection}
      hideContentAfterBreak
      selectable
    />
  );
});

function goToNote(note: t.Note) {
  history.pushState(null, '', `/n/${note.id}`);
}

export async function notesPageLoader(match: RouteMatch) {
  // Update app.showArchive and noteSelection when transitioning between / and /archive.
  appStore.update(app => {
    const showArchive = match.pathname === '/archive';
    if (showArchive !== app.showArchive) {
      app.showArchive = showArchive;
      app.noteSelection = undefined;
      // app.notesUpdateRequestTimestamp = Date.now();
    }
  });

  if (appStore.get().user) {
    log('notesPageLoader calling updateNotes');
    // Not awaiting this causes glitches especially when going from / to /archive and back with scroll restoration.
    await actions.updateNotes();
  }
}

function reduceNotePagesImmediately() {
  const notes = document.querySelectorAll('.note');
  for (const [i, note] of notes.entries()) {
    const rect = note.getBoundingClientRect();
    if (rect.top > window.innerHeight * 2 + window.scrollY) {
      actions.reduceNotePages(i);
      break;
    }
  }
}

const reduceNotePagesDebounced = _.debounce(reduceNotePagesImmediately, 1000);


================================================
FILE: src/client/Notifications.css
================================================
.msg-bar {
  position: fixed;
  bottom: 3rem;
  /* left: 0; */
  /* right: 0; */
  /* width: 100%; */
  /* border-top: 1px solid white; */
  color: white;
  font-size: 0.8em;
  margin-left: auto;
  margin-right: auto;
  max-width: var(--page-max-width);
  width: 100%;
  display: flex;
  justify-content: center;
  pointer-events: none;

  /* &.has-sticky { */
  /*   /\* justify-content: flex-end; *\/ */
  /*   font-size: 0.7em; */

  /*   &.error .msg-bar-inner-container { */
  /*     background: none; */
  /*     color: #b90000; */
  /*   } */
  /*   &.info .msg-bar-inner-container { */
  /*     background: none; */
  /*     color: #609dbb; */
  /*   } */

  /*   .msg-bar-inner-container { */
  /*     padding: 0; */
  /*     box-shadow: none; */
  /*     width: unset; */
  /*     text-decoration: underline; */
  /*     text-underline-offset: 2px; */
  /*   } */
  /* } */

  &.error .msg-bar-inner-container {
    background: #b90000ee;
  }
  &.info .msg-bar-inner-container {
    background: #609dbbee;
  }

  .msg-bar-inner-container {
    /* width: 100%; */
    padding: 0 2rem;
    border-radius: 3px;
    box-shadow: 0px 4px 14px 1px #00000022;

    & p {
      text-align: center;
      margin: 0;
      padding: 0.25rem 1rem;
    }
  }
}

.update-app-container {
  position: fixed;
  bottom: 3rem;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;

  & button {
    box-shadow: 0px 4px 14px 7px #00000022;
    width: 200px;
  }
}


================================================
FILE: src/client/Notifications.tsx
================================================
import * as actions from './appStoreActions.jsx';
import * as appStore from './appStore.js';
import React from 'react';

export default function Notifications() {
  const app = appStore.use();

  return (
    <>
      {app.message && (
        <div className={`msg-bar ${app.message.type} `}>
          <div className="msg-bar-inner-container">
            <p>{app.message.text.substring(0, 100)}</p>
          </div>
        </div>
      )}
      {app.requirePageRefresh && (
        <div className="update-app-container">
          <button className="primary" onClick={actions.updateApp}>
            Click to update app
          </button>
        </div>
      )}
    </>
  );
}


================================================
FILE: src/client/PageLayout.css
================================================
#page-header {
  z-index: var(--page-header-z-index);
  background: var(--page-header-background);
  position: fixed;
  top: 0;
  left: 0;
  right: 0;

  &.compact #page-header-inner-wrapper {
    height: var(--page-header-height);
    background: var(--page-header-background);
    box-shadow: 0px 4px 14px 1px #00000022;

    & h1 {
      font-size: 1rem;
      letter-spacing: 0px;
      font-family: monospace;
      color: white;
    }
  }

  &.has-search #page-header-inner-wrapper .first-row-content .title {
    @media (max-width: 500px) {
      display: none;
    }
  }

  #page-header-inner-wrapper {
    position: absolute;
    /* The value -1 is used to prevent a small gap from appearing especially on IOS Safari. */
    /* padding-top is then set in js. See NotePage.tsx. */
    /* top: -1px; */
    left: 0;
    right: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);

    .first-row-content {
      height: var(--page-header-height);
      margin: 0 auto;
      max-width: var(--page-max-width);
      width: 100%;
      position: relative;
      display: flex;
      align-items: center;
      background: var(--page-header-background);
      box-shadow: 0px 4px 14px 1px #00000022;

      .menu-button-container {
        display: flex;
        align-items: center;
        margin-left: 1rem;

        .menu-button {
          position: relative;
          width: 25px;
          height: 25px;

          > a {
            display: flex;
          }
        }
      }

      .title {
        position: relative;
        margin-left: 0.5rem;
        display: flex;
        align-items: center;
        white-space: nowrap;

        & h1 {
          margin: 0;
        }
        & h2 {
          margin: 0 0.5rem 0 0.5rem;
        }

        & h1,
        & h2 {
          font-size: 0.8rem;
          letter-spacing: 0px;
          font-family: monospace;
          color: white;
        }

        /* position: absolute; */
        /* bottom: 0.5rem; */
        /* left: calc(100% + 0.25rem); */
        /* text-align: left; */

        .queue-count {
          margin-left: 0.5rem;
          font-size: 0.7em;
          color: white;
        }
        /* .online-indicator { */
        /*   margin-left: 0.25rem; */
        /*   width: 7px; */
        /*   height: 7px; */
        /*   border-radius: 50%; */
        /*   background: #51bc51; */
        /* } */
      }

      .actions {
        margin-right: 1rem;
      }
    }

    .second-row-content {
      height: var(--page-header-second-row-height);
      margin: 0 auto;
      max-width: var(--page-max-width);
      width: 100%;
      position: relative;
      display: flex;
      align-items: center;
      border-top: 0.5px solid #fff;
      background: var(--page-header-second-row-background);
      box-shadow: 0px 4px 14px 1px #00000022;

      & h1.heading {
        margin: 0;
        margin-left: 1rem;
        white-space: nowrap;
        font-size: 0.8rem;
        letter-spacing: 0px;
        font-family: monospace;
        color: white;
      }

      .actions {
        margin-right: 1rem;

        /* .action img { */
        /*   width: 20px; */
        /*   height: 20px; */
        /* } */
      }
    }

    .actions {
      flex: 1 0 0;
      height: 100%;
      margin-left: 1rem;
      display: flex;
      align-items: center;
      justify-content: flex-end;

      .action + .action {
        margin-left: 1.5rem;

        @media (max-width: 800px) {
          margin-left: 1rem;
        }
      }

      & input.search + .action.close-search {
        margin-left: 0.5rem;
      }

      & input.search {
        border-radius: 0;
        border: 0;
        border-bottom: 0;
        /* flex: 1; */
        width: 100%;
        max-width: 300px;
        /* width: 500px; */
        /* margin: 0 auto; */
        /* border: 1px solid #aadfef; */
        /* border-radius: 5px; */
        outline: none;
        padding: 0.25rem 0.5rem;
      }

      .action {
        position: relative;
        display: flex;
        align-items: center;

        & > a {
          color: white;
          height: 100%;
          display: flex;
          align-items: center;

          @media (max-width: 800px) {
            font-size: 0.9em;
          }

          &.bold {
            font-weight: bold;
          }
        }
      }
    }
  }
}


================================================
FILE: src/client/PageLayout.tsx
================================================
import { useRouter } from './router.jsx';
import React, { useCallback, useState } from 'react';
import { useCallbackCancelEvent } from './hooks.js';
import * as actions from './appStoreActions.jsx';
import { Menu, MenuItem } from './Menu.jsx';
import * as appStore from './appStore.js';
import _ from 'lodash';
import * as icons from './icons.js';
import { sync, requireQueueSync } from './sync.js';

export function PageLayout(props: { children: React.ReactNode }) {
  return <>{props.children}</>;
}

export type PageHeaderSecondRowProps = {
  title: string;
  actions: React.ReactNode;
};

type PageHeaderProps = {
  menu?: MenuItem[];
  actions?: React.ReactNode;
  title?: string;
  hasSearch?: boolean;
  compact?: boolean;
  secondRow?: PageHeaderSecondRowProps;
};

export function PageHeader(props: PageHeaderProps) {
  return (
    <div id="page-header" className={`${props.hasSearch ? 'has-search' : ''} ${props.compact ? 'compact' : ''}`}>
      <div id="page-header-inner-wrapper">
        {props.compact ? <PageHeaderContentCompact /> : <PageHeaderFirstRowContent {...props} />}
        {props.secondRow && <PageHeaderSecondRowContent {...props.secondRow} />}
      </div>
    </div>
  );
}

function PageHeaderContentCompact() {
  return <h1 className="heading">Unforget</h1>;
}

function PageHeaderFirstRowContent(props: PageHeaderProps) {
  const app = appStore.use();
  if (!app.user) throw new Error('PageHeaderFirstRowContent requires user');
  const [menuOpen, setMenuOpen] = useState(false);

  const toggleMenu = useCallbackCancelEvent(() => setMenuOpen(x => !x), []);

  const fullSync = useCallback(() => {
    requireQueueSync();
    sync();
    actions.showMessage('Syncing ...');
  }, []);

  const forceCheckAppUpdate = useCallback(() => {
    actions.forceCheckAppUpdate();
    actions.showMessage('Checking for updates ...');
  }, []);

  const router = useRouter();

  function goToNotes(e?: React.UIEvent) {
    e?.preventDefault();
    e?.stopPropagation();
    setMenuOpen(false);
    if (router.pathname === '/') {
      window.scrollTo(0, 0);
    } else {
      history.pushState(null, '', '/');
    }
  }

  let menu: MenuItem[] | undefined;
  menu = _.compact([
    { label: _.upperFirst(app.user.username), icon: icons.user, isHeader: true },
    app.user.username === 'demo' && { label: 'Log in / Sign up', icon: icons.logIn, to: '/login' },
    ...(props.menu || []),
    { label: 'Notes', icon: icons.notes, onClick: goToNotes, to: '/' },
    { label: 'Archive', icon: icons.archiveEmpty, to: '/archive' },
    { label: 'Import', icon: icons.import, to: '/import' },
    { label: 'Export', icon: icons.export, to: '/export' },
    { label: 'About', icon: icons.info, to: '/about' },
    { label: 'Full sync', icon: icons.refreshCcw, onClick: fullSync, hasTopSeparator: true },
    { label: 'Check app updates', icon: icons.upgrade, onClick: forceCheckAppUpdate },
    { label: 'Log out', icon: icons.logOut, onClick: actions.logout, hasTopSeparator: true },
  ]);

  return (
    <div className="first-row-content">
      {!_.isEmpty(menu) && (
        <div className="menu-button-container">
          <div className="menu-button">
            <a href="#" onClick={toggleMenu} className="reset" id="page-header-menu-trigger">
              <img src={icons.menuWhite} />
            </a>
            {menuOpen && <Menu menu={menu!} side="left" onClose={toggleMenu} trigger="#page-header-menu-trigger" />}
          </div>
        </div>
      )}
      <div className="title">
        {/*
          <div className="logo">
            <Link to="/">
              <img src="/barefront.svg" />
            </Link>
            </div>
            */}
        <h1 className="heading">
          <a href="/" className="reset" onClick={goToNotes}>
            Unforget
          </a>
        </h1>
        {props.title && <h2>{props.title}</h2>}
        {app.user?.username !== 'demo' && app.queueCount > 0 && <div className="queue-count">({app.queueCount})</div>}
        {/*app.online && <div className="online-indicator" />*/}
      </div>
      <div className="actions">{props.actions}</div>
    </div>
  );
}

function PageHeaderSecondRowContent(props: PageHeaderSecondRowProps) {
  return (
    <div className="second-row-content">
      <h1 className="heading">{props.title}</h1>
      <div className="actions">{props.actions}</div>
    </div>
  );
}

export function PageBody(props: { children: React.ReactNode }) {
  return props.children;
}

export function PageAction(props: {
  className?: string;
  label?: string;
  icon?: string;
  onClick?: () => any;
  bold?: boolean;
  menu?: MenuItem[];
  title: string;
}) {
  const [menuOpen, setMenuOpen] = useState(false);
  const toggleMenu = useCallbackCancelEvent(() => setMenuOpen(x => !x), []);
  const clicked = useCallbackCancelEvent(() => {
    if (props.menu) toggleMenu();
    props.onClick?.();
  }, [props.menu, props.onClick]);

  // We need action-container because <a> cannot be nested inside another <a> which we need for the menu.
  return (
    <div
      className={`action ${props.className || ''}`}
      key={`${props.label || '_'} ${props.icon || '_'}`}
      title={props.title}
    >
      <a href="#" onClick={clicked} className={`page-action-menu-trigger reset ${props.bold ? 'bold' : ''}`}>
        {props.label}
        {props.icon && <img src={props.icon} />}
      </a>
      {props.menu && menuOpen && (
        <Menu menu={props.menu} side="center" onClose={toggleMenu} trigger=".page-action-menu-trigger" />
      )}
    </div>
  );
}


================================================
FILE: src/client/api.ts
================================================
import { ServerError, CACHE_VERSION } from '../common/util.js';

export async function post<T>(pathname: string, body?: any, params?: Record<string, string>): Promise<T> {
  const paramsStr = new URLSearchParams(params).toString();
  const res = await fetch(`${pathname}?${paramsStr}`, {
    // const res = await fetch(pathname, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Client-Cache-Version': String(CACHE_VERSION) },
    body: body && JSON.stringify(body),
  });
  if (!res.ok) {
    const error = await createServerError(res);
    // if (error.type === 'app_requires_update') {
    //   postMessageToServiceWorker({ command: 'update' });
    // }
    throw error;
  }
  return await res.json();
}

export async function createServerError(res: Response): Promise<ServerError> {
  const contentType = getResponseContentType(res);
  if (contentType === 'application/json') {
    return ServerError.fromJSON(await res.json());
  } else {
    console.error(await res.text());
    return new ServerError(`unknown response of type ${contentType}`, res.status);
  }
}

function getResponseContentType(res: Response): string | undefined {
  return res.headers.get('Content-Type')?.split(/\s*;\s*/g)[0];
}


================================================
FILE: src/client/appStore.tsx
================================================
import { produce } from 'immer';
import { useSyncExternalStore } from 'react';
import type * as t from '../common/types.js';

let store: t.AppStore;
let listeners: t.AppStoreListener[] = [];

export function get(): t.AppStore {
  return store;
}

export function set(newStore: t.AppStore) {
  const oldStore = store;
  store = newStore;
  for (const listener of listeners) listener(store, oldStore);
}

export function update(recipe: t.AppStoreRecipe) {
  set(produce(store, recipe));
}

export function addListener(listener: t.AppStoreListener) {
  listeners.push(listener);
  return () => removeListener(listener);
}

export function removeListener(listener: t.AppStoreListener) {
  const index = listeners.indexOf(listener);
  if (index !== -1) listeners.splice(index, 1);
}

export function use(): t.AppStore {
  return useSyncExternalStore(addListener, get);
}

declare global {
  var dev: any;
}

globalThis.dev ??= {};
globalThis.dev.getStore = get;


================================================
FILE: src/client/appStoreActions.tsx
================================================
import type * as t from '../common/types.js';
import * as storage from './storage.js';
import * as appStore from './appStore.js';
import * as actions from './appStoreActions.js';
import log from './logger.js';
import { generateEncryptionSalt, calcClientPasswordHash, makeEncryptionKey } from './crypto.js';
import { getUserTokenFromCookie, setUserCookies } from './cookies.js';
import { postToServiceWorker } from './clientToServiceWorkerApi.js';
import * as api from './api.js';
import { bytesToHexString, createNewNote } from '../common/util.jsx';
import _ from 'lodash';
import welcome1 from './notes/welcome1.md';
import { sync, syncDebounced } from './sync.js';
import * as b from './cross-context-broadcast.js';

export async function initAppStore() {
  // let showArchive = false;
  // let hidePinnedNotes = false;
  // let user: t.ClientLocalUser | undefined;

  // if (readFromStorage) {
  const [showArchive, hidePinnedNotes, user] = await Promise.all([
    storage.getSetting('showArchive').then(Boolean),
    storage.getSetting('hidePinnedNotes').then(Boolean),
    storage.getUser(),
  ]);
  // }

  appStore.set({
    showArchive,
    hidePinnedNotes,
    notes: [],
    // notesUpdateRequestTimestamp: 0,
    // notesUpdateTimestamp: -1,
    notePages: 1,
    notePageSize: 50,
    allNotePagesLoaded: false,
    online: navigator.onLine,
    queueCount: 0,
    syncing: false,
    updatingNotes: false,
    user,
    requirePageRefresh: false,
  });

  await updateNotes();
}

export async function setUpDemo() {
  const encryption_salt = bytesToHexString(generateEncryptionSalt());
  await loggedIn({ username: 'demo', password: 'demo' }, { username: 'demo', token: 'demo', encryption_salt });

  const notes: t.Note[] = [createNewNote(welcome1)];
  for (const note of notes) await saveNote(note);
  await updateNotes();
}

export async function updateNotes() {
  try {
    const start = Date.now();
    log('updateNotes started');
    const { notePages, notePageSize, hidePinnedNotes, search, showArchive } = appStore.get();
    // const notesUpdateTimestamp = Date.now();
    appStore.update(app => {
      app.updatingNotes = true;
    });
    const { done, notes } = await storage.getNotes({
      limit: notePageSize * notePages,
      hidePinnedNotes,
      search,
      archive: showArchive,
    });
    appStore.update(app => {
      app.notes = notes;
      app.allNotePagesLoaded = done;
      // app.notesUpdateTimestamp = notesUpdateTimestamp;
    });
    log(`updateNotes done in ${Date.now() - start}ms`);
  } catch (error) {
    gotError(error as Error);
  } finally {
    appStore.update(app => {
      app.updatingNotes = false;
    });
  }
}

export function reduceNotePages(lastItemIndex: number) {
  log(`trying to reduce note pages lastItemIndex: ${lastItemIndex}`);
  const { notePages, notePageSize } = appStore.get();
  const newNotePages = Math.floor((lastItemIndex + 1 + (notePageSize - 1)) / notePageSize);
  if (newNotePages < notePages) {
    log(`reducing note pages from ${notePages} to ${newNotePages}`);
    appStore.update(app => {
      app.notes = app.notes.slice(0, newNotePages * notePageSize);
      app.notePages = newNotePages;
    });
  }
}

// export async function updateNotesIfDirty() {
//   const { notesUpdateTimestamp, notesUpdateRequestTimestamp } = appStore.get();
//   if (notesUpdateTimestamp < notesUpdateRequestTimestamp) {
//     await updateNotes();
//   }
// }

export const updateNotesDebounced = _.debounce(updateNotes, 300, { leading: false, trailing: true, maxWait: 1000 });

export async function updateQueueCount() {
  try {
    const queueCount = await storage.countQueuedNotes();
    appStore.update(app => {
      app.queueCount = queueCount;
    });
  } catch (error) {
    gotError(error as Error);
  }
}

export async function login(credentials: t.UsernamePassword, opts?: { importDemoNotes?: boolean }) {
  try {
    const loginData: t.LoginData = {
      username: credentials.username,
      password_client_hash: await calcClientPasswordHash(credentials),
    };
    const loginResponse: t.LoginResponse = await api.post('/api/login', loginData);
    await loggedIn(credentials, loginResponse, opts);
  } catch (error) {
    gotError(error as Error);
  }
}

export async function signup(credentials: t.UsernamePassword, opts?: { importDemoNotes?: boolean }) {
  try {
    const signupData: t.SignupData = {
      username: credentials.username,
      password_client_hash: await calcClientPasswordHash(credentials),
      encryption_salt: bytesToHexString(generateEncryptionSalt()),
    };
    const loginResponse: t.LoginResponse = await api.post('/api/signup', signupData);

    // We want the client to pick the encryption salt to make sure it really is random and secure.
    if (loginResponse.encryption_salt !== signupData.encryption_salt) {
      await resetUser();
      throw new Error('Server might be compromised. The encryption parameters were tampered with.');
    }

    await loggedIn(credentials, loginResponse, opts);
  } catch (error) {
    gotError(error as Error);
  }
}

export async function logout() {
  try {
    const { user } = appStore.get();
    if (!user) return;

    // Calling a history API here may not work due to the timing.
    // It may still redirect to /login witht a from=XXX search param.
    // window.history.replaceState(null, '', '/');

    await resetUser();
    await storage.clearAll();
    await initAppStore();

    // Send token as param instead of relying on cookies because by the time the request is sent,
    // the cookie has already been cleared.
    api.post('/api/logout', null, { token: user.token }).catch(log.error);

    // Broadcast to all contexts that we just logged out.
    b.broadcast({ type: 'refreshPage' });
  } catch (error) {
    gotError(error as Error);
  }
}

export async function clearStorage() {
  try {
    await storage.clearAll();
    await initAppStore();
  } catch (error) {
    gotError(error as Error);
  }
}

export function gotError(error: Error) {
  log.error(error);
  showMessage(error.message, { type: 'error' });
}

export function showMessage(text: string, opts?: { type?: 'info' | 'error' }) {
  const timestamp = Date.now();
  appStore.update(app => {
    app.message = { text, type: opts?.type || 'info', timestamp };
  });
  setTimeout(() => {
    if (appStore.get().message?.timestamp === timestamp) {
      appStore.update(app => {
        app.message = undefined;
      });
    }
  }, 5000);
}

export async function saveNote(note: t.Note, opts?: { message?: string; immediateSync?: boolean }) {
  await saveNotes([note], opts);
}

export async function saveNotes(notes: t.Note[], opts?: { message?: string; immediateSync?: boolean }) {
  try {
    await storage.saveNotes(notes);
    if (opts?.message) {
      showMessage(opts.message, { type: 'info' });
    }
    // appStore.update(app => {
    //   app.notesUpdateRequestTimestamp = Date.now();
    // });
    if (opts?.immediateSync) {
      sync();
    } else {
      syncDebounced();
    }
    b.broadcast({ type: 'notesInStorageChanged' });
    // postToServiceWorker({ command: 'tellOthersNotesInStorageChanged' });
  } catch (error) {
    gotError(error as Error);
  }
}

// export async function clearNotes() {
//   await storage.clearNotes();
//   appStore.update(app => {
//     app.notes = [];
//     app.notesUpdateRequestTimestamp = Date.now();
//   });
// }

export async function saveNoteAndQuickUpdateNotes(note: t.Note) {
  try {
    await storage.saveNote(note);
    appStore.update(app => {
      const i = app.notes.findIndex(x => x.id === note.id);
      if (i !== -1) app.notes[i] = note;
    });
    sync();
    b.broadcast({ type: 'notesInStorageChanged' });
    // postToServiceWorker({ command: 'tellOthersNotesInStorageChanged' });
  } catch (error) {
    gotError(error as Error);
  }
}

export function toggleNoteSelection(note: t.Note) {
  appStore.update(app => {
    if (!app.noteSelection) app.noteSelection = [];

    const i = app.noteSelection.indexOf(note.id);
    if (i !== -1) {
      app.noteSelection.splice(i, 1);
    } else {
      app.noteSelection.push(note.id);
    }

    if (app.noteSelection.length === 0) {
      app.noteSelection = undefined;
    }
  });
}

export async function archiveNoteSelection() {
  const app = appStore.get();
  if (app.noteSelection?.length) {
    const notes = _.compact(await storage.getNotesById(app.noteSelection));
    const newNotes = notes.map(note => ({
      ...note,
      modification_date: new Date().toISOString(),
      not_archived: 0,
    }));
    await saveNotes(newNotes, { message: `Archived ${newNotes.length} note(s)`, immediateSync: true });
    appStore.update(app => {
      app.noteSelection = undefined;
    });
    await updateNotes();
  }
}

export async function unarchiveNoteSelection() {
  const app = appStore.get();
  if (app.noteSelection?.length) {
    const notes = _.compact(await storage.getNotesById(app.noteSelection));
    const newNotes = notes.map(note => ({
      ...note,
      modification_date: new Date().toISOString(),
      not_archived: 1,
    }));
    await saveNotes(newNotes, { message: `Unarchived ${newNotes.length} note(s)`, immediateSync: true });
    appStore.update(app => {
      app.noteSelection = undefined;
    });
    await updateNotes();
  }
}

export async function moveNoteSelectionUp() {
  await moveNoteSelection(storage.moveNotesUp);
}

export async function moveNoteSelectionDown() {
  await moveNoteSelection(storage.moveNotesDown);
}

export async function moveNoteSelectionToTop() {
  await moveNoteSelection(storage.moveNotesToTop);
}

export async function moveNoteSelectionToBottom() {
  await moveNoteSelection(storage.moveNotesToBottom);
}

async function moveNoteSelection(moveFn: (ids: string[]) => Promise<void>) {
  const app = appStore.get();
  if (app.noteSelection) {
    await moveFn(app.noteSelection);
    syncDebounced();
    b.broadcast({ type: 'notesInStorageChanged' });
    await updateNotes();
  }
}

async function makeClientLocalUserFromServer(
  credentials: t.UsernamePassword,
  loginResponse: t.LoginResponse,
): Promise<t.ClientLocalUser> {
  return {
    username: loginResponse.username,
    token: loginResponse.token,
    encryptionKey: await makeEncryptionKey(credentials.password, loginResponse.encryption_salt),
  };
}

/**
 * Mostly, useful during development if we manually delete one but not the other.
 * Just in case make sure that the token and the user in appStore are in sync.
 */
export async function makeSureConsistentUserAndCookie() {
  const tokenFromCookie = getUserTokenFromCookie();
  const { user } = appStore.get();
  const consistent = Boolean(user && tokenFromCookie && user.token === tokenFromCookie);
  if (!consistent) await actions.resetUser();
}

export async function resetUser() {
  setUserCookies('');
  await storage.clearUser();
  appStore.update(app => {
    app.user = undefined;
  });
}

async function loggedIn(
  credentials: t.UsernamePassword,
  loginResponse: t.LoginResponse,
  opts?: { importDemoNotes?: boolean },
) {
  const user = await makeClientLocalUserFromServer(credentials, loginResponse);
  if (!opts?.importDemoNotes) {
    await clearStorage();
  }
  setUserCookies(loginResponse.token); // Needed for the demo user.
  await storage.setUser(user);
  appStore.update(app => {
    app.user = user;
  });
  sync();
  b.broadcast({ type: 'refreshPage' });
  // postToServiceWorker({ command: 'tellOthersToRefreshPage' });
}

export async function checkAppUpdate() {
  try {
    if (!appStore.get().online) return;

    const updateInterval = process.env.NODE_ENV === 'development' ? 10 * 1000 : 24 * 3600 * 1000;
    const lastCheck = await storage.getSetting<string>('lastAppUpdateCheck');
    if (!lastCheck || new Date(lastCheck).valueOf() < Date.now() - updateInterval) {
      await checkAppUpdateHelper();
    }
  } catch (error) {
    gotError(error as Error);
  }
}

export async function forceCheckAppUpdate() {
  try {
    await checkAppUpdateHelper();
  } catch (error) {
    gotError(error as Error);
  }
}

async function checkAppUpdateHelper() {
  const registration = await navigator.serviceWorker.ready;
  await registration.update();
  await storage.setSetting(new Date().toISOString(), 'lastAppUpdateCheck');
}

export async function requireAppUpdate() {
  appStore.update(app => {
    app.message = undefined;
    app.requirePageRefresh = true;
  });
}

export async function updateApp() {
  try {
    await storage.setSetting(true, 'updatingApp');
    window.location.reload();
  } catch (error) {
    gotError(error as Error);
  }
}

export async function notifyIfAppUpdated() {
  try {
    const updatingApp = await storage.getSetting('updatingApp');
    if (updatingApp) {
      showMessage('App updated (details in the about page)');
      await storage.setSetting(false, 'updatingApp');
    }
  } catch (error) {
    gotError(error as Error);
  }
}


================================================
FILE: src/client/clientToServiceWorkerApi.ts
================================================
import type { ClientToServiceWorkerMessage } from '../common/types.js';
import log from './logger.js';

export async function postToServiceWorker(message: ClientToServiceWorkerMessage) {
  try {
    const reg = await navigator.serviceWorker?.ready;
    reg?.active?.postMessage(message);
  } catch (error) {
    log.error(error);
  }
}


================================================
FILE: src/client/common.css
================================================
:root {
  --app-background: #ddf7ff;
  --page-header-height: 2rem;
  --page-header-second-row-height: 2rem;
  --page-header-background: #448199;
  --page-header-second-row-background: #366f85;
  --menu-header-background: #d6f5ff;
  --box-border-color: #94becd;
  --input-border-color: #aadfef;
  --box-border-radius: 5px;
  --page-header-z-index: 200;
  --margin-after-page-header: 2rem;
  --page-content-margin-top: calc(var(--page-header-height) + var(--margin-after-page-header));
  --page-content-margin-bottom: 2rem;
  --page-max-width: 800px;
  --menu-z-index: 100;
  --sticky-z-index: 50;
  --line-height: 1.4;
  --checkbox-size: 25px;
  --single-space-size: 5px;
  --checkbox-right-margin: 7px;
  --bulletpoint-size: 6px;
  /* --checkbox-size-with-margin: calc( */
  /*   var(--checkbox-size) + var(--checkbox-first-margin) + var(--checkbox-second-margin) */
  /* ); */
  --checkbox-accent: #197af8;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  font-size: 16px;
}

html {
  line-height: var(--line-height);
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji,
    Segoe UI Emoji, Segoe UI Symbol;
}

button,
input,
optgroup,
select,
textarea {
  line-height: var(--line-height);
}

body {
  background: var(--app-background);
  display: flex;
  flex-direction: column;
}

#app {
  flex: 1;
  display: flex;
  flex-direction: column;
}

h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol,
pre,
blockquote,
table {
  margin: 0.5rem 0;
}

blockquote {
  margin-left: 2rem;
}

ul ol,
ol ul,
ul ul,
ol ol {
  margin: 0.25rem 0;
}
li + li {
  margin-top: 0.25rem;
}

button {
  background: var(--app-background);
  border: 1px solid #448199;
  padding: 0.5rem;
  border-radius: 5px;
  transition: 0.2s background ease-in-out;
  color: #000;

  &:hover {
    background: #cfecf5;
  }

  &:focus-visible {
    outline: 1px solid #448199;
  }

  &.primary {
    background: #448199;
    color: #fff;

    &:hover {
      background: #3e778e;
    }

    /* &:focus-visible { */
    /* outline: 1px solid #448199; */
    /* } */
  }

  &.button-row {
    margin-left: auto;
    margin-right: auto;
    padding-left: 1rem;
    padding-right: 1rem;
    /* width: 400px; */

    @media (max-width: 800px) {
      border-radius: 0;
      width: 100%;
    }
  }
}

/* p { */
/* margin: 0; */
/* } */

/* p + p { */
/*   margin-top: 1rem; */
/* } */

.text-input {
  /* width: 500px; */
  /* margin: 0 auto; */
  border: 1px solid var(--input-border-color);
  border-radius: 5px;
  outline: none;
  padding: 0.5rem 0.75rem;

  &:not(.small) {
    @media (max-width: 800px) {
      border-radius: 0;
      border-left: 0;
      border-right: 0;
    }
  }
}

/* .file { */
/*   position: relative; */
/*   display: inline-block; */
/*   cursor: pointer; */
/*   height: 2.5rem; */
/*   text-align: left; */

/*   & input { */
/*     min-width: 14rem; */
/*     margin: 0; */
/*     opacity: 0; */
/*   } */
/*   & .file-custom { */
/*     position: absolute; */
/*     top: 0; */
/*     right: 0; */
/*     left: 0; */
/*     z-index: 5; */
/*     height: 1.5rem; */
/*     padding: 0.5rem 1rem; */
/*     line-height: 1.5; */
/*     color: #555; */
/*     background-color: #fff; */
/*     border: 1px solid var(--input-border-color); */
/*     /\* border: 0.075rem solid #ddd; *\/ */
/*     /\* border-radius: 0.25rem; *\/ */
/*     outline: none; */
/*     border-radius: 5px; */
/*     /\* box-shadow: inset 0 0.2rem 0.4rem rgba(0, 0, 0, 0.05); *\/ */
/*     user-select: none; */
/*     overflow: hidden; */

/*     &::before { */
/*       position: absolute; */
/*       top: -0.075rem; */
/*       right: -0.075rem; */
/*       bottom: -0.075rem; */
/*       z-index: 6; */
/*       display: block; */
/*       content: 'Browse'; */
/*       height: 1.5rem; */
/*       padding: 0.5rem 1rem; */
/*       line-height: 1.5; */
/*       color: #333; */
/*       background-color: white; */
/*       border: 0.075rem solid #ddd; */
/*       border-radius: 0 0.25rem 0.25rem 0; */
/*     } */

/*     .custom-label { */
/*       white-space: nowrap; */
/*     } */
/*   } */
/* } */

.page {
  flex: 1;
  margin: var(--page-content-margin-top) auto var(--page-content-margin-bottom) auto;
  max-width: var(--page-max-width);
  width: 100%;
  position: relative;
  display: flex;
  flex-direction: column;
}

h2.page-message {
  margin-top: 3rem;
  text-align: center;
  color: #666;
}

a.reset {
  &,
  &:visited,
  &:focus,
  &:active {
    color: inherit;
    text-decoration: none;
  }
}


================================================
FILE: src/client/cookies.ts
================================================
import log from './logger.js';

export function getCookie(name: string): string | undefined {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith(name + '='))
    ?.split('=')[1];
}

export function getUserTokenFromCookie(): string | undefined {
  return getCookie('unforget_token');
}

export function setUserCookies(token: string) {
  const maxAge = 10 * 365 * 24 * 3600; // 10 years in seconds
  document.cookie = `unforget_token=${token}; max-age=${maxAge}; path=/`;
}


================================================
FILE: src/client/cross-context-broadcast.ts
================================================
import type { BroadcastChannelMessage } from '../common/types.js';
import log from './logger.js';

export type Listener = (msg: BroadcastChannelMessage) => any;

let channel: BroadcastChannel;
let listeners: Listener[] = [];

export function init() {
  channel = new BroadcastChannel('unforget');

  channel.onmessage = event => {
    const message: BroadcastChannelMessage = event.data;
    log('broadcast received:', message);

    for (const listener of listeners) {
      try {
        listener(message);
      } catch (error) {
        log.error(error);
      }
    }
  };
}

export function addListener(listener: Listener) {
  listeners.push(listener);
}

export function removeListener(listener: Listener) {
  const i = listeners.indexOf(listener);
  if (i !== -1) listeners.splice(i, 1);
}

/**
 * Broadcasts will not be received in the same context. They are only recieved by other contexts (other tabs/windows)
 */
export function broadcast(message: Omit<BroadcastChannelMessage, 'unforgetContextId'>) {
  const fullMessage: BroadcastChannelMessage = { unforgetContextId: window.unforgetContextId, ...message };
  channel.postMessage(fullMessage);
}


================================================
FILE: src/client/crypto.ts
================================================
import type * as t from '../common/types.js';
import { bytesToHexString, hexStringToBytes } from '../common/util.js';
import log from './logger.js';

/**
 * Derive from username, password, and a static random number
 */
export async function calcClientPasswordHash({ username, password }: t.UsernamePassword): Promise<string> {
  const text = username + password + '32261572990560219427182644435912532';
  const encoder = new TextEncoder();
  const textBuf = encoder.encode(text);
  const hashBuf = await crypto.subtle.digest('SHA-256', textBuf);
  return bytesToHexString(new Uint8Array(hashBuf));
}

export function generateEncryptionSalt(): Uint8Array<ArrayBuffer> {
  return crypto.getRandomValues(new Uint8Array(16));
}

export function generateIV(): Uint8Array<ArrayBuffer> {
  return crypto.getRandomValues(new Uint8Array(12));
}

export async function bytesToBase64(buffer: ArrayBuffer): Promise<string> {
  // Note: using FileReader.readAsDataURL() is about 30x slower.
  let binaryString = '';
  const bytes = new Uint8Array(buffer);
  const len = bytes.byteLength;
  for (let i = 0; i < len; i++) {
    binaryString += String.fromCharCode(bytes[i]);
  }
  const res = btoa(binaryString);
  return res;
}

export async function base64ToBytes(base64: string): Promise<ArrayBuffer> {
  // Note: using await (await fetch(dataUrl)).arrayBuffer() is about 50x slower
  const binaryString = atob(base64);
  const len = binaryString.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  return bytes.buffer;
}

export async function makeEncryptionKey(password: string, salt: string): Promise<CryptoKey> {
  const keyData = new TextEncoder().encode(password);
  const keyMaterial = await crypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']);

  const saltBuf = hexStringToBytes(salt);
  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: saltBuf,
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt'],
  );
}

export async function exportEncryptionKey(key: CryptoKey): Promise<JsonWebKey> {
  return crypto.subtle.exportKey('jwk', key);
}

export async function importEncryptionKey(key: JsonWebKey): Promise<CryptoKey> {
  return crypto.subtle.importKey('jwk', key, 'AES-GCM', true, ['encrypt', 'decrypt']);
}

export async function encrypt(data: BufferSource, key: CryptoKey): Promise<t.EncryptedData> {
  const iv = generateIV();
  const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
  const encrypted_base64 = await bytesToBase64(encrypted);
  return { encrypted_base64, iv: bytesToHexString(iv) };
}

export async function decrypt(data: t.EncryptedData, key: CryptoKey): Promise<ArrayBuffer> {
  const encryptedBytes = await base64ToBytes(data.encrypted_base64);
  const iv = hexStringToBytes(data.iv);
  return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes);
}

export async function encryptNotes(notes: t.Note[], key: CryptoKey): Promise<t.EncryptedNote[]> {
  const start = performance.now();
  const res: t.EncryptedNote[] = [];
  for (const note of notes) {
    res.push(await encryptNote(note, key));
  }
  if (res.length) log(`encrypted ${res.length} notes in ${performance.now() - start}ms`);
  return res;
}

export async function encryptNote(note: t.Note, key: CryptoKey): Promise<t.EncryptedNote> {
  const data = new TextEncoder().encode(JSON.stringify(note));
  const encrypted = await encrypt(data, key);
  return { id: note.id, modification_date: note.modification_date, ...encrypted };
}

export async function decryptNotes(notes: t.EncryptedNote[], key: CryptoKey): Promise<t.Note[]> {
  const start = performance.now();
  const res: t.Note[] = [];
  for (const note of notes) {
    res.push(await decryptNote(note, key));
  }
  if (res.length) log(`decrypted ${res.length} notes in ${performance.now() - start}ms`);
  return res;
}

export async function decryptNote(note: t.EncryptedNote, key: CryptoKey): Promise<t.Note> {
  const decryptedData = await decrypt(note, key);
  const noteString = new TextDecoder().decode(decryptedData);
  return JSON.parse(noteString) as t.Note;
}


================================================
FILE: src/client/custom.d.ts
================================================
declare module '*.svg' {
  const content: string;
  export default content;
}
declare module '*.txt' {
  const content: string;
  export default content;
}
declare module '*.md' {
  const content: string;
  export default content;
}
// declare module '*.svg' {
//   const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
//   export default content;
// }

interface Window {
  unforgetContextId: string;
}


================================================
FILE: src/client/hooks.tsx
================================================
import { useRouter, storeScrollY } from './router.jsx';
import React, { useCallback, useState, useEffect, useLayoutEffect } from 'react';
import _ from 'lodash';

export function useInterval(cb: () => void, ms: number) {
  useEffect(() => {
    const interval = setInterval(cb, ms);
    return () => clearInterval(interval);
  }, []);
}

export function useCallbackCancelEvent(cb: () => any, deps: React.DependencyList): (e?: React.UIEvent) => void {
  return useCallback((e?: React.UIEvent) => {
    if (e) {
      e.preventDefault();
      e.stopPropagation();
    }
    cb();
  }, deps);
}

export function useClickWithoutDrag(cb: React.MouseEventHandler): {
  onClick: React.MouseEventHandler;
  onMouseDown: React.MouseEventHandler;
} {
  const [mouseDownPos, setMouseDownPos] = useState<[number, number] | undefined>();
  const onMouseDown = useCallback((e: React.MouseEvent) => {
    setMouseDownPos([e.clientX, e.clientY]);
  }, []);
  const onClick = useCallback(
    (e: React.MouseEvent) => {
      if (mouseDownPos) {
        const diff = [Math.abs(e.clientX - mouseDownPos[0]), Math.abs(e.clientY - mouseDownPos[1])];
        const dist = Math.sqrt(diff[0] ** 2 + diff[1] ** 2);
        if (dist < 5) return cb(e);
      }
    },
    [cb, mouseDownPos],
  );

  return { onClick, onMouseDown };
}

export function useStoreAndRestoreScrollY() {
  const { state } = useRouter();
  useLayoutEffect(() => {
    window.scrollTo(0, state?.scrollY ?? 0);

    const storeScrollYRateLimited = _.debounce(storeScrollY, 100, { leading: false, trailing: true });
    window.addEventListener('scroll', storeScrollYRateLimited);
    return () => window.removeEventListener('scroll', storeScrollYRateLimited);
  }, [state?.index]);
}


================================================
FILE: src/client/icons.ts
================================================
export { default as addWhite } from '../../public/icons/add-white.svg';
export { default as archiveEmpty } from '../../public/icons/archive-empty.svg';
export { default as archiveEmptyWhite } from '../../public/icons/archive-empty-white.svg';
export { default as archiveFilled } from '../../public/icons/archive-filled.svg';
export { default as archiveFilledWhite } from '../../public/icons/archive-filled-white.svg';
// export { default as bulletpointWhite } from '../../public/icons/bulletpoint-white.svg';
export { default as cycleListWhite } from '../../public/icons/cycle-list-white.svg';
// export { default as checkboxList } from '../../public/icons/checkbox-list.svg';
export { default as check } from '../../public/icons/check.svg';
export { default as checkWhite } from '../../public/icons/check-white.svg';
export { default as hidePinnedWhite } from '../../public/icons/hide-pinned-white.svg';
export { default as hidePinnedWhite2 } from '../../public/icons/hide-pinned-white-2.svg';
export { default as info } from '../../public/icons/info.svg';
export { default as logOut } from '../../public/icons/log-out.svg';
export { default as logIn } from '../../public/icons/log-in.svg';
export { default as menuWhite } from '../../public/icons/menu-white.svg';
export { default as notes } from '../../public/icons/notes.svg';
export { default as pinEmpty } from '../../public/icons/pin-empty.svg';
export { default as pinEmptyWhite } from '../../public/icons/pin-empty-white.svg';
export { default as pinFilled } from '../../public/icons/pin-filled.svg';
export { default as pinFilledWhite } from '../../public/icons/pin-filled-white.svg';
export { default as refreshCcw } from '../../public/icons/refresh-ccw.svg';
export { default as upgrade } from '../../public/icons/upgrade.svg';
export { default as searchWhite } from '../../public/icons/search-white.svg';
export { default as showPinnedWhite } from '../../public/icons/show-pinned-white.svg';
export { default as trashWhite } from '../../public/icons/trash-white.svg';
export { default as user } from '../../public/icons/user.svg';
export { default as xWhite } from '../../public/icons/x-white.svg';
export { default as googleKeep } from '../../public/icons/google-keep.svg';
export { default as export } from '../../public/icons/export.svg';
export { default as import } from '../../public/icons/import.svg';
export { default as chevronUpWhite } from '../../public/icons/chevron-up-white.svg';
export { default as chevronUpDoubleWhite } from '../../public/icons/chevron-up-double-white.svg';
export { default as chevronDownWhite } from '../../public/icons/chevron-down-white.svg';
export { default as chevronDownDoubleWhite } from '../../public/icons/chevron-down-double-white.svg';
// export { default as selectCircleWhite } from '../../public/icons/select-circle-white.svg';
export { default as circleSelectWhite } from '../../public/icons/circle-select-white.svg';
export { default as circleDeselectWhite } from '../../public/icons/circle-deselect-white.svg';


================================================
FILE: src/client/index.tsx
================================================
import type * as t from '../common/types.js';
import { createRoot } from 'react-dom/client';
import * as storage from './storage.js';
import { setUpManualScrollRestoration, patchHistory } from './router.jsx';
import React from 'react';
import App from './App.jsx';
import { postToServiceWorker } from './clientToServiceWorkerApi.js';
import * as appStore from './appStore.jsx';
import * as actions from './appStoreActions.jsx';
import { CACHE_VERSION } from '../common/util.js';
import log from './logger.js';
import { sync, syncInInterval, addSyncEventListener, type SyncEvent } from './sync.js';
import * as b from './cross-context-broadcast.js';
import { v4 as uuid } from 'uuid';

async function setup() {
  // Set up unique context id.
  window.unforgetContextId = uuid();

  // Set up broadcast.
  b.init();
  b.addListener(handleBroadcastMessage);

  // Set up storage before registering the service worker.
  // Because the service worker itself will try to set up the storage too.
  await storage.getStorage();

  // Initialize app store.
  // Must do before registering service worker because we need to update
  // the appStore in reaction to messages from the service worker.
  await actions.initAppStore();
  await actions.makeSureConsistentUserAndCookie();

  await registerServiceWorker();

  // // Tell the service worker there's a new window.
  // await postToServiceWorker({ command: 'newClient' });

  // Request sync status from service worker.
  // await postToServiceWorker({ command: 'sendSyncStatus' });

  // Sync online status.
  function onlineChanged() {
    appStore.update(app => {
      app.online = navigator.onLine;
    });
  }
  window.addEventListener('online', onlineChanged);
  window.addEventListener('offline', onlineChanged);

  // Listen to sync events.
  addSyncEventListener(handleSyncEvent);

  // Sync online status periodically.
  setInterval(onlineChanged, 5000);

  // Sync when online.
  window.addEventListener('online', () => {
    sync();
    // postToServiceWorker({ command: 'sync' });
  });

  // Check for app updates when page becomes visible.
  window.addEventListener('visibilitychange', function visibilityChanged() {
    if (document.visibilityState === 'visible') {
      actions.checkAppUpdate();
    }
  });

  // Check for app updates when online.
  window.addEventListener('online', actions.checkAppUpdate);

  // Check for app updates periodically.
  setInterval(actions.checkAppUpdate, 10 * 1000);

  // Initial check for app updates.
  actions.checkAppUpdate();

  // Update queue count periodically.
  setInterval(actions.updateQueueCount, 3 * 1000);

  // Notify user if app updated.
  actions.notifyIfAppUpdated();

  // Manual scroll restoration.
  setUpManualScrollRestoration();

  // Patch history required for our router.
  patchHistory();

  // Sync in interval
  syncInInterval();

  const root = createRoot(document.getElementById('app')!);
  root.render(<App />);
}

async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) {
    log.error('window: service workers are not supported.');
    alert('Your browser does not support service workers. Please use another browser.');
    return;
  }

  navigator.serviceWorker.addEventListener('message', event => {
    log(`window: received message from service worker`, event.data);
    handleServiceWorkerMessage(event.data);
  });

  try {
    await navigator.serviceWorker.register('/serviceWorker.js');
    log('window: service worker registration successful');
  } catch (error) {
    actions.showMessage('Failed to register service worker: ' + (error as Error).message, { type: 'error' });
    log.error((error as Error).message);
  }

  // // Sometimes the service worker just gets disabled on iphone. I don't know why.
  // // Here, we try to register every 5s. According to MDN, it'll automatically
  // // check if there's already a registration.
  // setInterval(registerServiceWorkerHelper, 5000);
}

async function handleServiceWorkerMessage(message: t.ServiceWorkerToClientMessage) {
  switch (message.command) {
    case 'serviceWorkerActivated': {
      if (message.cacheVersion > CACHE_VERSION) {
        log(`window: require a page refresh to upgrade from ${CACHE_VERSION} to ${message.cacheVersion}`);
        actions.requireAppUpdate();
      }

      break;
    }
    case 'error': {
      actions.showMessage(message.error, { type: 'error' });
      break;
    }

    default:
      console.log('Unknown message', message);
  }
}

async function handleSyncEvent(event: SyncEvent) {
  switch (event.type) {
    case 'error': {
      actions.showMessage(event.error.message, { type: 'error' });
      break;
    }
    case 'mergedNotes': {
      b.broadcast({ type: 'notesInStorageChanged' });
      break;
    }
    case 'syncStatus': {
      appStore.update(app => {
        app.syncing = event.syncing;
      });
      break;
    }
    case 'unauthorized': {
      await actions.resetUser();
      b.broadcast({ type: 'refreshPage' });
      window.location.reload();
      break;
    }
  }
}

function handleBroadcastMessage(message: t.BroadcastChannelMessage) {
  switch (message.type) {
    case 'notesInStorageChanged': {
      break; // Will listen to and handle this in specific pages.
    }

    case 'refreshPage': {
      window.location.reload();
      break;
    }
  }
}

window.onload = setup;


================================================
FILE: src/client/logger.ts
================================================
import * as api from './api.js';

function log(...args: any[]) {
  if (Number(process.env.LOG_TO_CONSOLE)) {
    console.log(...args);
  }
  if (Number(process.env.FORWARD_LOGS_TO_SERVER)) {
    api.post('/api/log', { message: stringify(...args) });
  }
}

log.error = function error(...args: any[]) {
  console.error(...args);
  if (Number(process.env.FORWARD_ERRORS_TO_SERVER)) {
    api.post('/api/error', { message: stringify(...args) });
  }
};

function stringify(...args: any[]): string {
  let strs = [];
  for (const arg of args) {
    if (typeof arg === 'string') {
      strs.push(arg);
    } else if (arg instanceof Error) {
      strs.push(arg.toString());
    } else {
      strs.push(JSON.stringify(arg));
    }
  }
  return strs.join(' ');
}

export default log;


================================================
FILE: src/client/normalize.css
================================================
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */

/* Document
   ========================================================================== */

/**
 * 1. Correct the line height in all browsers.
 * 2. Prevent adjustments of font size after orientation changes in iOS.
 */

html {
  line-height: 1.15; /* 1 */
  -webkit-text-size-adjust: 100%; /* 2 */
}

/* Sections
   ========================================================================== */

/**
 * Remove the margin in all browsers.
 */

body {
  margin: 0;
}

/**
 * Render the `main` element consistently in IE.
 */

main {
  display: block;
}

/**
 * Correct the font size and margin on `h1` elements within `section` and
 * `article` contexts in Chrome, Firefox, and Safari.
 */

h1 {
  font-size: 2em;
  margin: 0.67em 0;
}

/* Grouping content
   ========================================================================== */

/**
 * 1. Add the correct box sizing in Firefox.
 * 2. Show the overflow in Edge and IE.
 */

hr {
  box-sizing: content-box; /* 1 */
  height: 0; /* 1 */
  overflow: visible; /* 2 */
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

pre {
  font-family: monospace, monospace; /* 1 */
  font-size: 1em; /* 2 */
}

/* Text-level semantics
   ========================================================================== */

/**
 * Remove the gray background on active links in IE 10.
 */

a {
  background-color: transparent;
}

/**
 * 1. Remove the bottom border in Chrome 57-
 * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
 */

abbr[title] {
  border-bottom: none; /* 1 */
  text-decoration: underline; /* 2 */
  text-decoration: underline dotted; /* 2 */
}

/**
 * Add the correct font weight in Chrome, Edge, and Safari.
 */

b,
strong {
  font-weight: bolder;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

code,
kbd,
samp {
  font-family: monospace, monospace; /* 1 */
  font-size: 1em; /* 2 */
}

/**
 * Add the correct font size in all browsers.
 */

small {
  font-size: 80%;
}

/**
 * Prevent `sub` and `sup` elements from affecting the line height in
 * all browsers.
 */

sub,
sup {
  font-size: 75%;
  line-height: 0;
  position: relative;
  vertical-align: baseline;
}

sub {
  bottom: -0.25em;
}

sup {
  top: -0.5em;
}

/* Embedded content
   ========================================================================== */

/**
 * Remove the border on images inside links in IE 10.
 */

img {
  border-style: none;
}

/* Forms
   ========================================================================== */

/**
 * 1. Change the font styles in all browsers.
 * 2. Remove the margin in Firefox and Safari.
 */

button,
input,
optgroup,
select,
textarea {
  font-family: inherit; /* 1 */
  font-size: 100%; /* 1 */
  line-height: 1.15; /* 1 */
  margin: 0; /* 2 */
}

/**
 * Show the overflow in IE.
 * 1. Show the overflow in Edge.
 */

button,
input {
  /* 1 */
  overflow: visible;
}

/**
 * Remove the inheritance of text transform in Edge, Firefox, and IE.
 * 1. Remove the inheritance of text transform in Firefox.
 */

button,
select {
  /* 1 */
  text-transform: none;
}

/**
 * Correct the inability to style clickable types in iOS and Safari.
 */

button,
[type='button'],
[type='reset'],
[type='submit'] {
  -webkit-appearance: button;
}

/**
 * Remove the inner border and padding in Firefox.
 */

button::-moz-focus-inner,
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner {
  border-style: none;
  padding: 0;
}

/**
 * Restore the focus styles unset by the previous rule.
 */

button:-moz-focusring,
[type='button']:-moz-focusring,
[type='reset']:-moz-focusring,
[type='submit']:-moz-focusring {
  outline: 1px dotted ButtonText;
}

/**
 * Correct the padding in Firefox.
 */

fieldset {
  padding: 0.35em 0.75em 0.625em;
}

/**
 * 1. Correct the text wrapping in Edge and IE.
 * 2. Correct the color inheritance from `fieldset` elements in IE.
 * 3. Remove the padding so developers are not caught out when they zero out
 *    `fieldset` elements in all browsers.
 */

legend {
  box-sizing: border-box; /* 1 */
  color: inherit; /* 2 */
  display: table; /* 1 */
  max-width: 100%; /* 1 */
  padding: 0; /* 3 */
  white-space: normal; /* 1 */
}

/**
 * Add the correct vertical alignment in Chrome, Firefox, and Opera.
 */

progress {
  vertical-align: baseline;
}

/**
 * Remove the default vertical scrollbar in IE 10+.
 */

textarea {
  overflow: auto;
}

/**
 * 1. Add the correct box sizing in IE 10.
 * 2. Remove the padding in IE 10.
 */

[type='checkbox'],
[type='radio'] {
  box-sizing: border-box; /* 1 */
  padding: 0; /* 2 */
}

/**
 * Correct the cursor style of increment and decrement buttons in Chrome.
 */

[type='number']::-webkit-inner-spin-button,
[type='number']::-webkit-outer-spin-button {
  height: auto;
}

/**
 * 1. Correct the odd appearance in Chrome and Safari.
 * 2. Correct the outline style in Safari.
 */

[type='search'] {
  -webkit-appearance: textfield; /* 1 */
  outline-offset: -2px; /* 2 */
}

/**
 * Remove the inner padding in Chrome and Safari on macOS.
 */

[type='search']::-webkit-search-decoration {
  -webkit-appearance: none;
}

/**
 * 1. Correct the inability to style clickable types in iOS and Safari.
 * 2. Change font properties to `inherit` in Safari.
 */

::-webkit-file-upload-button {
  -webkit-appearance: button; /* 1 */
  font: inherit; /* 2 */
}

/* Interactive
   ========================================================================== */

/*
 * Add the correct display in Edge, IE 10+, and Firefox.
 */

details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */

summary {
  display: list-item;
}

/* Misc
   ========================================================================== */

/**
 * Add the correct display in IE 10+.
 */

template {
  display: none;
}

/**
 * Add the correct display in IE 10.
 */

[hidden] {
  display: none;
}


================================================
FILE: src/client/notes/about.md
================================================
# About

Unforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.

Unforget is MIT Licensed.

Check out our [GitHub](https://github.com/computing-den/unforget).

Contact us at sean@computing-den.com



# Release Notes

### 25 October 2025
- Moved notifications to the bottom of the page
- Fixed a bug where pasting text would overwrite current line

### 02 August 2025
- Show selection during search
- Escape always cancels search first and then selection
- Fixed issue with the header jumping down on IOS
- Show the pin icon on pinned notes during selection
- Auto format pasted text (convert to list/checkbox, fix indentation, etc.)
- More bug fixes

### 17 August 2024
- Bug fixes: shortcut conflicts

### 16 August 2024
- Select and move notes
- Import Unforget's own exported JSON
- Improved sync for mobile devices

### 22 June 2024
- Import Apple Notes
- Include labels as tags when importing Google Keep
- Show creation and modification dates of notes
- Fix a rare syncing issue

### 13 June 2024
- Import from Standard Notes
- Warn when picking a weak password
- Do not allow empty password
- [Explain organization and workflow of notes as well as security and privacy](https://github.com/computing-den/unforget/blob/master/README.md)
- Fix some typos


================================================
FILE: src/client/notes/export.md
================================================
# Export as JSON

[Click here](#export-json) to export notes in JSON format.

The JSON file will contain an array of notes where the type of each note is:

```
type Note = {

  // UUID version 4
  id: string;

  // Deleted notes have null text.
  text: string | null;

  // In ISO 8601 format
  creation_date: string;
  
  // In ISO 8601 format
  modification_date: string;
  
  // 0 means deleted, 1 means not deleted
  not_deleted: number;
  
  // 0 means archived, 1 means not archived
  not_archived: number;
  
  // 0 means not pinned, 1 means pinned
  pinned: number;

  // A higher number means higher on the list.
  // Usually, by default it's milliseconds since the epoch
  order: number;

}
```


================================================
FILE: src/client/notes/import.md
================================================

*Note: the import process is done entirely on your device to preserve your privacy.*

# Google Keep

1. Go to [Google Takout](https://takeout.google.com/)
2. Select only Keep's data for export
   *it'll be ready for download in a few minutes*
3. [Click here](#keep) to import notes from the zip file
   - [ ] include labels as tags


# Apple Notes

1. Go to https://privacy.apple.com/
2. Request a copy of your data
3. Select only iCloud Notes for export
4. Complete request
   *it'll be ready for download in a couple of days*
5. [Click here](#apple) to import notes from the zip file
   - [ ] include folder names as tags

# Standard Notes

1. Open Standard Notes app or [website](https://app.standardnotes.com/)
2. Select your notes and press Export to download the zip file
3. [Click here](#standard) to import notes from the zip file

# Unforget

1. From the menu, pick export and download the JSON file
2. [Click here](#unforget) to import notes from the JSON file


# CLI and Public APIs

Please check out the [GitHub page](https://github.com/computing-den/unforget?tab=readme-ov-file#public-apis---write-your-own-client) for the instructions.


================================================
FILE: src/client/notes/welcome1.md
================================================
# Welcome!

Unforget is a minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js) featuring:

- [x] Offline first
- [x] Privacy first
- [x] Progressive web app
- [x] Open source MIT License
- [x] End-to-end encrypted sync
- [x] Desktop, Mobile, Web
- [x] Markdown support
- [x] Self hosted and cloud options
- [x] One-click data export as JSON
- [x] Optional one-click installation
- [x] Public APIs, create your own client
- [x] Import Google Keep
- [x] Import Apple Notes
- [x] Import Standard Notes


*Unforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technologies.*

*Contact us at sean@computing-den.com*

# Easy Signup

[Sign up](/login) for free to back up your notes safely to the cloud fully encrypted and sync across devices.

*No email or phone required.*

# Optional installation

Use it directly in your browser or install:

| Browser         | Installation                |
|-----------------|-----------------------------|
| Chrome          | Install icon in the URL bar |
| Edge            | Install icon in the URL bar |
| Android Browser | Menu → Add to Home Screen   |
| Safari Desktop  | Share → Add to Dock         |
| Safari iOS      | Share → Add to Home Screen  |
| Firefox Desktop | *cannot install*            |
| Firefox Android | Install icon in the URL bar |

# Organization and Workflow

---

The notes are organized **chronologically**, with pinned notes displayed at the top.

This 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.

There 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.

Notes are **immediately saved** as you type and synced every few seconds.

If you edit a note from two devices and a **conflict** occurs during sync, the most recent edit will take precedence.

# Security and Privacy

Unforget 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.

Only your username and note modification dates are visible to Unforget servers.

# Text Formatting

The main differences with the [Github flavored markdown](https://github.github.com/gfm/) are:
- If the first line of a note is followed by a blank line, it is a H1 header.
- Anything after the first horizontal rule `---` in a note will be hidden and replaced with a "show more" button that will expand the note.

~~~
# H1 header
## H2 header
### H3 header
#### H4 header
##### H5 header
###### H6 header

*This is italic.*.

**This is bold.**.

***This is bold and italic.***

~~This is strikethrough~~


- This is a bullet point
- Another bullet point
  - Inner bullet point
- [ ] This is a checkbox
  And more text related to the checkbox.

1. This is an ordered list item
2. And another one

[this is a link](https://unforget.computing-den.com)

Inline `code` using back-ticks.

Block of code:

```javascript
function plusOne(a) {
  return a + 1;
}
```


| Tables        | Are           | Cool  |
| ------------- |:-------------:| -----:|
| col 3 is      | right-aligned | $1600 |
| col 2 is      | centered      |   $12 |


Horizontal rule:

---


~~~


================================================
FILE: src/client/router.tsx
================================================
import React, {
  useMemo,
  useCallback,
  useContext,
  createContext,
  useDeferredValue,
  useSyncExternalStore,
  Suspense,
} from 'react';
import log from './logger.js';

export type HistoryState = { index: number; scrollY?: number };

export type Route = {
  path: string;
  element: React.ReactNode | ((match: RouteMatch) => React.ReactNode);
  loader?: Loader;
};

export type Loader = (match: RouteMatch) => Promise<any>;

export type RouterCtxType = {
  match?: RouteMatch;
  search: string;
  pathname: string;
  state: HistoryState;
  loaderData?: WrappedPromise<any>;
};

export type RouterLoadingCtxType = {
  isLoading: boolean;
};

export type Params = Record<string, string>;
// export type FallbackArgs = {isLoading: boolean};

export type RouteMatch = { route: Route; params: Params; pathname: string };

type WrappedPromise<T> = { read: () => T; status: 'pending' | 'success' | 'error' };

const RouterCtx = createContext<RouterCtxType>({ pathname: '/', search: '', state: { index: 0 } });
const RouterLoadingCtx = createContext<RouterLoadingCtxType>({ isLoading: false });
// const dataLoaderCache = new Map<string, WrappedPromise<any>>();

export function Router(props: { routes: Route[]; fallback: React.ReactNode }) {
  const pathname = useWindowLocationPathname();
  const search = useWindowLocationSearch();
  const state = useWindowHistoryState();
  const match = useMemo(() => matchRoute(pathname, props.routes), [pathname, props.routes]);
  const routerCtxValue: RouterCtxType = useMemo(() => {
    log('Creating router context for ', pathname);
    return {
      match,
      pathname,
      search,
      state,
      loaderData: match?.route.loader && wrapPromise(match.route.loader(match)),
    };
  }, [match, pathname, search, state]);
  const deferredCtxValue = useDeferredValue(routerCtxValue);

  const routerLoadingCtx = {
    isLoading: deferredCtxValue !== routerCtxValue,
  };
  log('router: isLoading: ', routerLoadingCtx.isLoading);

  // NOTE It is important that Suspense is not above the Router, otherwise when an element suspends,
  // the useMemo's and useState's of the Router will be called more than once.
  return (
    <Suspense fallback={props.fallback}>
      <RouterCtx.Provider value={deferredCtxValue}>
        <RouterLoadingCtx.Provider value={routerLoadingCtx}>
          <Suspender>{deferredCtxValue.match?.route.element}</Suspender>
        </RouterLoadingCtx.Provider>
      </RouterCtx.Provider>
    </Suspense>
  );
}

export function Link(props: { to: string; className?: string; children: React.ReactNode }) {
  const clickCb = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      e.stopPropagation();
      history.pushState(null, '', props.to);
    },
    [props.to],
  );
  return (
    <a href={props.to} onClick={clickCb} className={props.className}>
      {props.children}
    </a>
  );
}

function Suspender(props: { children: Route['element'] }) {
  const { loaderData, match } = useContext(RouterCtx);
  loaderData?.read(); // It'll throw a promise if not yet resolved.
  if (typeof props.children === 'function') {
    return props.children(match!);
  } else {
    return props.children;
  }
}

export function useRouterLoading(): RouterLoadingCtxType {
  return useContext(RouterLoadingCtx);
}

export function useRouter(): RouterCtxType {
  return useContext(RouterCtx);
}

function matchRoute(pathname: string, routes: Route[]): RouteMatch | undefined {
  const actualParts = pathname.split('/');
  for (const route of routes) {
    const expectedParts = route.path.split('/');
    const params = matchParts(actualParts, expectedParts);
    if (params) return { route, params, pathname };
  }
}

function matchParts(actualParts: string[], expectedParts: string[]): Params | undefined {
  if (actualParts.length !== expectedParts.length) return;

  const params: Params = {};
  for (let i = 0; i < actualParts.length; i++) {
    if (expectedParts[i].startsWith(':')) {
      params[expectedParts[i].substring(1)] = actualParts[i];
    } else if (actualParts[i] !== expectedParts[i]) {
      return;
    }
  }
  return params;
}

const eventPopstate = 'popstate';
const eventPushState = 'pushstate';
const eventReplaceState = 'replacestate';
const eventHashchange = 'hashchange';
const events = [eventPopstate, eventPushState, eventReplaceState, eventHashchange];

// export const navigate = (to: string, opts?: { replace?: boolean; state?: any }) =>
//   opts?.replace ? window.history.replaceState(opts?.state, '', to) : window.history.pushState(opts?.state, '', to);

export const useWindowLocationPathname = () => useSyncExternalStore(subscribeToHistoryUpdates, getLocationPathname);
function getLocationPathname(): string {
  return window.location.pathname;
}

export const useWindowLocationSearch = () => useSyncExternalStore(subscribeToHistoryUpdates, getLocationSearch);
function getLocationSearch(): string {
  return window.location.search;
}

export const useWindowHistoryState = () => useSyncExternalStore(subscribeToHistoryUpdates, getHistoryState);
function getHistoryState(): HistoryState {
  return window.history.state;
}

function subscribeToHistoryUpdates(callback: () => void) {
  for (const event of events) {
    window.addEventListener(event, callback);
  }
  return () => {
    for (const event of events) {
      window.removeEventListener(event, callback);
    }
  };
}

function wrapPromise<T>(promise: Promise<T>): WrappedPromise<T> {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let error: Error | undefined;
  let value: T | undefined;

  promise
    .then(v => {
      status = 'success';
      value = v;
    })
    .catch(e => {
      status = 'error';
      error = e;
    });

  return {
    get status() {
      return status;
    },
    read() {
      if (status === 'pending') throw promise;
      if (status === 'error') throw error!;
      return value!;
    },
  };
}

function assertHistoryStateType(data: any) {
  if (data !== null && data !== undefined && typeof data !== 'object')
    throw new Error('Please provide an object as history state');
}

let origReplaceState: (data: any, unused: string, url?: string | URL | null) => void;
let origPushState: (data: any, unused: string, url?: string | URL | null) => void;

/**
 * For some reason the browser sometimes does it properly and sometimes not.
 * Probably due to the whole React suspense and the delay in page transition.
 * So, we do it manually.
 */
export function setUpManualScrollRestoration() {
  window.history.scrollRestoration = 'manual';
}

export function storeScrollY() {
  const state: HistoryState = { ...window.history.state, scrollY: window.scrollY };
  origReplaceState.call(window.history, state, ''); // Won't dispatch any events.
}

/**
 * Monkey patch window.history to dispatch 'pushstate' and 'replacestate' events.
 * Also keep extra data the history state:
 *   index: number so that for example we know if history.back() can be called.
 */
export function patchHistory() {
  origPushState = window.history.pushState;
  origReplaceState = window.history.replaceState;
  window.history.pushState = function pushState(data: any, unused: string, url?: string | URL | null) {
    try {
      log(`pushState (patched) ${url} started`);
      assertHistoryStateType(data);
      const state: HistoryState = { ...data, index: window.history.state.index + 1 };
      origPushState.call(this, state, unused, url);
      const event = new Event(eventPushState);
      window.dispatchEvent(event);
      log(`pushState (patched) ${url} done`);
    } catch (error) {
      log.error(error);
    }
  };
  window.history.replaceState = function replaceState(data: any, unused: string, url?: string | URL | null) {
    try {
      assertHistoryStateType(data);
      const state: HistoryState = { ...data, index: window.history.state.index };
      origReplaceState.call(this, state, unused, url);
      const event = new Event(eventReplaceState);
      window.dispatchEvent(event);
    } catch (error) {
      log.error(error);
    }
  };

  // Initialize history.state
  if (!Number.isFinite(window.history.state?.index)) {
    origReplaceState.call(window.history, { index: 0 }, '');
  }
}


================================================
FILE: src/client/serviceWorker.ts
================================================
// Default type of `self` is `WorkerGlobalScope & typeof globalThis`
// https://github.com/microsoft/TypeScript/issues/14877
declare var self: ServiceWorkerGlobalScope;

import * as storage from './storage.js';
// import { sync, syncInInterval, requireQueueSync, syncDebounced, isSyncing } from './serviceWorkerSync.js';
import { postToClients } from './serviceWorkerToClientApi.js';
import type { ClientToServiceWorkerMessage } from '../common/types.js';
import { CACHE_VERSION, ServerError } from '../common/util.js';
import log from './logger.js';

const CACHE_NAME = `unforget-${CACHE_VERSION}`;
const APP_STATIC_RESOURCES = ['/', '/style.css', '/index.js', '/manifest.json', '/icon-256x256.png'];

self.addEventListener('install', event => {
  // The promise that skipWaiting() returns can be safely ignored.
  // Causes a newly installed service worker to progress into the activating state,
  // regardless of whether there is already an active service worker.
  self.skipWaiting();
  event.waitUntil(installServiceWorker());
});

// NOTE: The activate event is triggered only once after the install event.
self.addEventListener('activate', event => {
  event.waitUntil(activateServiceWorker());
});

// On fetch, intercept server requests
// and respond with cached responses instead of going to network
self.addEventListener('fetch', event => {
  event.respondWith(handleFetch(event));
});

// Listen to messages from window.
self.addEventListener('message', async event => {
  try {
    const message = event.data as ClientToServiceWorkerMessage;
    log('service worker: received message: ', message);
    if (event.source instanceof Client) {
      await handleClientMessage(event.source, message);
    }
  } catch (error) {
    log.error(error);
  }
});

async function installServiceWorker() {
  log('service worker: installing...');

  // Cache the static resources.
  const cache = await caches.open(CACHE_NAME);
  cache.addAll(APP_STATIC_RESOURCES);

  log('service worker: install done.');
}

// NOTE: The activate event is triggered only once after the install event.
async function activateServiceWorker() {
  log('service worker: activating...');

  // Delete old caches.
  const names = await caches.keys();
  await Promise.all(
    names.map(name => {
      if (name !== CACHE_NAME) {
        return caches.delete(name);
      }
    }),
  );

  // Set up storage.
  await storage.getStorage();

  // Take control of the clients and refresh them.
  // The refresh is necessary if the activate event was triggered by updateApp().
  await self.clients.claim();
  log('service worker: activated.');

  log('service worker: informing clients of serviceWorkerActivated with cacheVersion', CACHE_VERSION);
  postToClients({ command: 'serviceWorkerActivated', cacheVersion: CACHE_VERSION });
}

async function handleFetch(event: FetchEvent): Promise<Response> {
  const url = new URL(event.request.url);
  const { mode, method } = event.request;
  log('service worker fetch: ', mode, method, url.pathname);

  let response: Response | undefined;

  // As a single page app, direct app to always go to cached home page.
  if (mode === 'navigate') {
    response = await caches.match('/');
  } else if (method === 'GET' && !Number(process.env.DISABLE_CACHE)) {
    const cache = await caches.open(CACHE_NAME);
    response = await cache.match(event.request);
  }

  if (response) return response;

  try {
    response = await fetch(event.request, {
      headers: new Headers([...event.request.headers, ['X-Service-Worker-Cache-Version', String(CACHE_VERSION)]]),
    });
  } catch (error) {
    return Response.error();
  }

  if (!response.ok) {
    try {
      const clonedResponse = response.clone();
      const error = ServerError.fromJSON(await clonedResponse.json());
      if (error.type === 'app_requires_update') {
        await self.registration.update();
      }
    } catch (error) {
      log.error(error);
    }
  }

  return response;
}

async function handleClientMessage(client: Client, message: ClientToServiceWorkerMessage) {
  // NOTE: Nothing to handle any more.
  // switch (message.command) {
  //   // case 'update': {
  //   //   await self.registration.update();
  //   //   break;
  //   // }
  //   // case 'newClient': {
  //   //   // syncInInterval();
  //   //   break;
  //   // }
  //   default:
  //     log('Unknown message: ', message);
  // }
}


================================================
FILE: src/client/serviceWorkerToClientApi.ts
================================================
// Default type of `self` is `WorkerGlobalScope & typeof globalThis`
// https://github.com/microsoft/TypeScript/issues/14877
declare var self: ServiceWorkerGlobalScope;
import type { ServiceWorkerToClientMessage } from '../common/types.js';
import log from './logger.js';

export function postToClient(client: Client, message: ServiceWorkerToClientMessage) {
  client.postMessage(message);
}

export async function postToClients(message: ServiceWorkerToClientMessage, options?: { exceptClientIds?: string[] }) {
  try {
    const clients = await self.clients.matchAll();
    for (const client of clients) {
      if (!options?.exceptClientIds?.includes(client.id)) {
        postToClient(client, message);
      }
    }
  } catch (error) {
    log.error(error);
  }
}


================================================
FILE: src/client/storage.ts
================================================
import type * as t from '../common/types.js';
import * as cutil from '../common/util.jsx';
import { exportEncryptionKey, importEncryptionKey } from './crypto.js';
import _ from 'lodash';
import log from './logger.js';

let _db: IDBDatabase | undefined;

export const DB_NAME = 'unforget';
export const NOTES_STORE = 'notes';
export const NOTES_STORE_ORDER_INDEX = 'orderIndex';
export const NOTES_QUEUE_STORE = 'notesQueue';
export const SETTINGS_STORE = 'settings';

type SaveNoteQueueItem = { note: t.Note; resolve: () => void; reject: (error: Error) => any };

let saveNoteQueue: SaveNoteQueueItem[] = [];
let saveNoteQueueActive: boolean = false;

export async function getStorage(): Promise<IDBDatabase> {
  if (!_db) {
    _db = await new Promise<IDBDatabase>((resolve, reject) => {
      log('setting up storage');
      const dbOpenReq = indexedDB.open(DB_NAME, 53);

      dbOpenReq.onerror = () => {
        _db = undefined;
        reject(dbOpenReq.error);
      };

      dbOpenReq.onupgradeneeded = e => {
        // By comparing e.oldVersion with e.newVersion, we can perform only the actions needed for the upgrade.
        if (e.oldVersion < 52) {
          const notesStore = dbOpenReq.result.createObjectStore(NOTES_STORE, { keyPath: 'id' });
          notesStore.createIndex(NOTES_STORE_ORDER_INDEX, ['order']);
          dbOpenReq.result.createObjectStore(NOTES_QUEUE_STORE, { keyPath: 'id' });
          dbOpenReq.result.createObjectStore(SETTINGS_STORE);
        }
        if (e.oldVersion < 53) {
          const notesStore = dbOpenReq.transaction!.objectStore(NOTES_STORE);
          notesStore.deleteIndex(NOTES_STORE_ORDER_INDEX);
          notesStore.createIndex(NOTES_STORE_ORDER_INDEX, ['not_archived', 'not_deleted', 'pinned', 'order']);
        }
      };

      dbOpenReq.onsuccess = () => {
        resolve(dbOpenReq.result);
      };
    });

    _db.onversionchange = () => {
      _db?.close();
      log('new version of database is ready. Closing the database ...');
    };
    _db.onclose = () => {
      _db = undefined;
      log('database is closed');
    };
    _db.onabort = () => {
      log('database is aborted');
    };
    _db.onerror = error => {
      log.error(error);
    };
  }

  return _db;
}

export async function transaction<T>(
  storeNames: string | Iterable<string>,
  mode: IDBTransactionMode,
  callback: (tx: IDBTransaction) => T,
): Promise<T> {
  const db = await getStorage();
  return new Promise<T>(async (resolve, reject) => {
    let tx: IDBTransaction | undefined;
    try {
      tx = db.transaction(storeNames, mode);
      let res: T;
      tx.oncomplete = () => {
        // log('transaction succeeded');
        resolve(res);
      };
      tx.onerror = () => {
        log('transaction error', tx!.error);
        reject(tx!.error);
      };
      res = await callback(tx);
    } catch (error) {
      try {
        tx?.abort();
      } catch (error2) {
        log.error('transaction abort() failed', error2);
      } finally {
        reject(error);
      }
    }
  });
}

export async function saveNote(note: t.Note) {
  return saveNotes([note]);
}

export async function saveNotes(notes: t.Note[]) {
  const promises = notes.map(enqueueNote);
  saveNextNotesInQueue(); // Don't await here.
  await Promise.all(promises);
}

function enqueueNote(note: t.Note): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    saveNoteQueue.unshift({ note, resolve, reject });
  });
}

async function saveNextNotesInQueue() {
  if (saveNoteQueueActive) return;
  if (saveNoteQueue.length === 0) return;

  const items = [...saveNoteQueue];
  saveNoteQueue.length = 0;

  try {
    await saveNoteQueueItems(items);
    log(
      'saved notes ',
      items.map(item => item.note.text),
    );
    for (const item of items) item.resolve();
  } catch (error) {
    for (const item of items) item.reject(error as Error);
  } finally {
    saveNoteQueueActive = false;
    saveNextNotesInQueue(); // Don't await here.
  }
}

async function saveNoteQueueItems(items: SaveNoteQueueItem[]) {
  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', tx => {
    for (const item of items) {
      tx.objectStore(NOTES_STORE).put(item.note);
      tx.objectStore(NOTES_QUEUE_STORE).put(createNoteHeadFromNote(item.note));
    }
  });
}

function createNoteHeadFromNote(note: t.Note): t.NoteHead {
  return { id: note.id, modification_date: note.modification_date };
}

export async function moveNotesUp(ids: string[]) {
  const start = performance.now();
  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {
    const notesStore = tx.objectStore(NOTES_STORE);
    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);
    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);

    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));
    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'desc');

    for (const note of notes) {
      // Get the newer note
      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, note.order];
      const upperKey = [note.not_archived, note.not_deleted, note.pinned, Infinity];
      const newerNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey, true, true));
      const newerNoteCursorRes = await waitForDBRequest(newerNoteCursorReq);
      const newerNote: t.Note = newerNoteCursorRes?.value;

      // Skip if not found.
      if (!newerNote) continue;

      // // The newerNote may not actually be newer if it differs in not_archived, not_deleted, or pinned.
      // if (newerNote.order <= note.order) continue;

      // Don't jump over a note in the selection. In other words, the relative order of selection
      // stays the same.
      if (notes.find(n => n.id === newerNote.id)) continue;

      // Swap the orders and set modification time.
      [note.order, newerNote.order] = [newerNote.order, note.order];
      note.modification_date = newerNote.modification_date = new Date().toISOString();

      // Save both
      await Promise.all([
        waitForDBRequest(notesStore.put(note)),
        waitForDBRequest(notesStore.put(newerNote)),
        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(note))),
        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(newerNote))),
      ]);
    }
  });
  log(`moveNotesUp done in ${performance.now() - start}ms`);
}

export async function moveNotesDown(ids: string[]) {
  const start = performance.now();
  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {
    const notesStore = tx.objectStore(NOTES_STORE);
    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);
    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);

    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));
    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'asc');

    // Going in reverse order (oldest to newest notes).
    for (const note of notes) {
      // Get the older note.
      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, 0];
      const upperKey = [note.not_archived, note.not_deleted, note.pinned, note.order];
      const olderNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey, true, true), 'prev');
      const olderNoteCursorRes = await waitForDBRequest(olderNoteCursorReq);
      const olderNote: t.Note = olderNoteCursorRes?.value;

      // Skip if not found.
      if (!olderNote) continue;

      // Don't jump over a note in the selection. In other words, the relative order of selection
      // stays the same.
      if (notes.find(n => n.id === olderNote.id)) continue;

      // // The olderNote may not actually be older if it differs in not_archived, not_deleted, or pinned.
      // if (olderNote.order >= note.order) continue;

      // Swap the orders and set modification time.
      [note.order, olderNote.order] = [olderNote.order, note.order];
      note.modification_date = olderNote.modification_date = new Date().toISOString();

      // Save both
      await Promise.all([
        waitForDBRequest(notesStore.put(note)),
        waitForDBRequest(notesStore.put(olderNote)),
        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(note))),
        waitForDBRequest(notesQueueStore.put(createNoteHeadFromNote(olderNote))),
      ]);
    }
  });
  log(`moveNotesDown done in ${performance.now() - start}ms`);
}

export async function moveNotesToTop(ids: string[]) {
  const start = performance.now();
  await transaction([NOTES_STORE, NOTES_QUEUE_STORE], 'readwrite', async tx => {
    const notesStore = tx.objectStore(NOTES_STORE);
    const notesQueueStore = tx.objectStore(NOTES_QUEUE_STORE);
    const orderIndex = notesStore.index(NOTES_STORE_ORDER_INDEX);

    const sparseNotes = await Promise.all(ids.map(id => waitForDBRequest<t.Note | undefined>(notesStore.get(id))));
    const notes = _.orderBy(_.compact(sparseNotes), 'order', 'asc');

    // Going in reverse order (oldest to newest notes).
    // Must do this one at a time because we can't get the absolute max/min order (unless we create a new index) and
    // some of the notes may differ in not_archived, not_deleted, and pinned.
    for (const note of notes) {
      // Get the newest note
      const lowerKey = [note.not_archived, note.not_deleted, note.pinned, 0];
      const upperKey = [note.not_archived, note.not_deleted, note.pinned, Infinity];
      const newestNoteCursorReq = orderIndex.openCursor(IDBKeyRange.bound(lowerKey, upperKey), 'prev');
      const newestNoteCursorRes = await waitForDBRequest(newestNoteCursorReq);
      const newestNote: t.Note = newestNoteCursorRes?.value;

      // Skip if not found.
      if (!newestNote) continue;

      // set
Download .txt
gitextract_bzf8m7j0/

├── .gitignore
├── .ignore
├── .prettierrc
├── LICENSE.txt
├── README.md
├── esbuild.config.mjs
├── examples/
│   ├── example.py
│   └── example.ts
├── package.json
├── public/
│   └── manifest.json
├── scripts/
│   ├── script.ts
│   └── tsconfig.json
├── src/
│   ├── client/
│   │   ├── AboutPage.tsx
│   │   ├── App.tsx
│   │   ├── DemoPage.tsx
│   │   ├── Editor.css
│   │   ├── Editor.tsx
│   │   ├── ExportPage.tsx
│   │   ├── ImportPage.tsx
│   │   ├── LoginPage.css
│   │   ├── LoginPage.tsx
│   │   ├── Menu.css
│   │   ├── Menu.tsx
│   │   ├── NotePage.css
│   │   ├── NotePage.tsx
│   │   ├── Notes.css
│   │   ├── Notes.tsx
│   │   ├── NotesPage.css
│   │   ├── NotesPage.tsx
│   │   ├── Notifications.css
│   │   ├── Notifications.tsx
│   │   ├── PageLayout.css
│   │   ├── PageLayout.tsx
│   │   ├── api.ts
│   │   ├── appStore.tsx
│   │   ├── appStoreActions.tsx
│   │   ├── clientToServiceWorkerApi.ts
│   │   ├── common.css
│   │   ├── cookies.ts
│   │   ├── cross-context-broadcast.ts
│   │   ├── crypto.ts
│   │   ├── custom.d.ts
│   │   ├── hooks.tsx
│   │   ├── icons.ts
│   │   ├── index.tsx
│   │   ├── logger.ts
│   │   ├── normalize.css
│   │   ├── notes/
│   │   │   ├── about.md
│   │   │   ├── export.md
│   │   │   ├── import.md
│   │   │   └── welcome1.md
│   │   ├── router.tsx
│   │   ├── serviceWorker.ts
│   │   ├── serviceWorkerToClientApi.ts
│   │   ├── storage.ts
│   │   ├── style.css
│   │   └── sync.ts
│   ├── common/
│   │   ├── mdFns.ts
│   │   ├── types.ts
│   │   └── util.ts
│   └── server/
│       ├── db.ts
│       ├── index.ts
│       └── validateEnvVars.ts
└── tsconfig.json
Download .txt
SYMBOL INDEX (349 symbols across 38 files)

FILE: esbuild.config.mjs
  function reporterPlugin (line 26) | function reporterPlugin() {

FILE: examples/example.py
  class Note (line 19) | class Note:
    method __init__ (line 20) | def __init__(self, id, text, creation_date, modification_date, not_del...
  class EncryptedNote (line 30) | class EncryptedNote:
    method __init__ (line 31) | def __init__(self, id, modification_date, encrypted_base64, iv):
  class LoginData (line 37) | class LoginData:
    method __init__ (line 38) | def __init__(self, username, password_client_hash):
  class SignupData (line 42) | class SignupData(LoginData):
    method __init__ (line 43) | def __init__(self, username, password_client_hash, encryption_salt):
  class LoginResponse (line 47) | class LoginResponse:
    method __init__ (line 48) | def __init__(self, username, token, encryption_salt):
  class Credentials (line 53) | class Credentials(LoginResponse):
    method __init__ (line 54) | def __init__(self, username, token, encryption_salt, jwk):
  function main (line 58) | def main():
  function usage_and_exit (line 88) | def usage_and_exit():
  function signup (line 99) | def signup(username, password):
  function login (line 114) | def login(username, password):
  function create_note (line 127) | def create_note(text):
  function get_note (line 147) | def get_note(id):
  function encrypt_note (line 165) | def encrypt_note(note, key):
  function decrypt_note (line 179) | def decrypt_note(encrypted_note, key):
  function read_credentials (line 193) | def read_credentials():
  function write_credentials (line 198) | def write_credentials(credentials):
  function import_key (line 204) | def import_key(credentials):
  function create_credentials (line 209) | def create_credentials(res, password):
  function post (line 224) | def post(pathname, body, credentials=None):
  function calc_password_hash (line 235) | def calc_password_hash(username, password):
  function bytes_to_hex_string (line 241) | def bytes_to_hex_string(bytes):
  function hex_string_to_bytes (line 245) | def hex_string_to_bytes(hex_string):

FILE: examples/example.ts
  type Note (line 4) | type Note = {
  type EncryptedNote (line 31) | type EncryptedNote = {
  type LoginData (line 45) | type LoginData = {
  type SignupData (line 50) | type SignupData = {
  type LoginResponse (line 56) | type LoginResponse = {
  type Credentials (line 66) | type Credentials = LoginResponse & { jwk: webcrypto.JsonWebKey };
  constant BASE_URL (line 68) | const BASE_URL = 'https://unforget.computing-den.com';
  function main (line 70) | async function main() {
  function usageAndExit (line 107) | function usageAndExit() {
  function signup (line 119) | async function signup(username: string, password: string) {
  function login (line 128) | async function login(username: string, password: string) {
  function createNote (line 136) | async function createNote(text: string) {
  function getNote (line 157) | async function getNote(id?: string) {
  function encryptNote (line 176) | async function encryptNote(note: Note, key: webcrypto.CryptoKey): Promis...
  function decryptNote (line 198) | async function decryptNote(encryptedNote: EncryptedNote, key: webcrypto....
  function readCredentials (line 216) | function readCredentials(): Credentials {
  function writeCredentials (line 223) | function writeCredentials(credentials: Credentials) {
  function importKey (line 232) | async function importKey(credentials: Credentials): Promise<CryptoKey> {
  function createCredentials (line 241) | async function createCredentials(res: LoginResponse, password: string): ...
  function post (line 266) | async function post<T>(pathname: string, body?: any, credentials?: Crede...
  function calcPasswordHash (line 283) | async function calcPasswordHash(username: string, password: string): Pro...
  function bytesToHexString (line 294) | function bytesToHexString(bytes: Uint8Array): string {
  function hexStringToBytes (line 303) | function hexStringToBytes(str: string): Uint8Array {

FILE: src/client/AboutPage.tsx
  function AboutPage (line 12) | function AboutPage() {

FILE: src/client/App.tsx
  function App (line 15) | function App() {
  function Fallback (line 82) | function Fallback() {
  function Auth (line 86) | function Auth(props: { children: React.ReactNode }) {

FILE: src/client/DemoPage.tsx
  function DemoPage (line 5) | function DemoPage() {

FILE: src/client/Editor.tsx
  type EditorProps (line 11) | type EditorProps = {
  type EditorContext (line 27) | type EditorContext = {
  type Selection (line 33) | type Selection = { start: number; end: number; direction: 'forward' | 'b...
  function replaceText (line 40) | function replaceText(deleteStart: number, deleteEnd: number, text: strin...
  function replaceListItemPrefix (line 54) | function replaceListItemPrefix(listItem: md.ListItem, newListItem: md.Li...
  function cycleListStyle (line 60) | function cycleListStyle() {
  function focus (line 91) | function focus() {
  function changeCb (line 101) | function changeCb() {
  function keyDownCb (line 105) | function keyDownCb(e: React.KeyboardEvent) {
  function clickCb (line 166) | function clickCb(e: React.MouseEvent) {
  function pasteCb (line 193) | function pasteCb(e: React.ClipboardEvent<HTMLTextAreaElement>) {

FILE: src/client/ExportPage.tsx
  function ExportPage (line 13) | function ExportPage() {
  function offerDownload (line 35) | function offerDownload(filename: string, text: string) {

FILE: src/client/ImportPage.tsx
  type ImportKeys (line 22) | type ImportKeys = keyof typeof importers;
  function ImportPage (line 24) | function ImportPage() {
  function importUnforget (line 104) | async function importUnforget(jsonFile: File): Promise<t.Note[]> {
  function importKeep (line 108) | async function importKeep(zipFile: File, note: t.Note): Promise<t.Note[]> {
  function validateGoogleKeepJson (line 152) | function validateGoogleKeepJson(json: any): string | undefined {
  function importApple (line 164) | async function importApple(zipFile: File, note: t.Note): Promise<t.Note[...
  function importStandard (line 203) | async function importStandard(zipFile: File): Promise<t.Note[]> {
  function hasOption (line 232) | function hasOption(text: string, label: string): boolean {

FILE: src/client/LoginPage.tsx
  type LoginPageProps (line 8) | type LoginPageProps = {};
  function LoginPage (line 10) | function LoginPage(props: LoginPageProps) {

FILE: src/client/Menu.tsx
  type MenuItem (line 4) | type MenuItem = {
  type MenuProps (line 13) | type MenuProps = { menu: MenuItem[]; side: 'left' | 'right' | 'center'; ...
  function Menu (line 15) | function Menu(props: MenuProps) {

FILE: src/client/NotePage.tsx
  function NotePage (line 16) | function NotePage() {
  function notePageLoader (line 212) | async function notePageLoader({ params }: RouteMatch): Promise<t.Note | ...

FILE: src/client/Notes.tsx
  function Notes (line 18) | function Notes(props: {
  function toggleExpanded (line 66) | function toggleExpanded(e: React.MouseEvent) {
  function clickCb (line 78) | function clickCb(e: React.MouseEvent) {
  function selectCb (line 133) | function selectCb(e: React.MouseEvent) {

FILE: src/client/NotesPage.tsx
  type NotesPageProps (line 19) | type NotesPageProps = {};
  function NotesPage (line 21) | function NotesPage(_props: NotesPageProps) {
  function goToNote (line 447) | function goToNote(note: t.Note) {
  function notesPageLoader (line 451) | async function notesPageLoader(match: RouteMatch) {
  function reduceNotePagesImmediately (line 469) | function reduceNotePagesImmediately() {

FILE: src/client/Notifications.tsx
  function Notifications (line 5) | function Notifications() {

FILE: src/client/PageLayout.tsx
  function PageLayout (line 11) | function PageLayout(props: { children: React.ReactNode }) {
  type PageHeaderSecondRowProps (line 15) | type PageHeaderSecondRowProps = {
  type PageHeaderProps (line 20) | type PageHeaderProps = {
  function PageHeader (line 29) | function PageHeader(props: PageHeaderProps) {
  function PageHeaderContentCompact (line 40) | function PageHeaderContentCompact() {
  function PageHeaderFirstRowContent (line 44) | function PageHeaderFirstRowContent(props: PageHeaderProps) {
  function PageHeaderSecondRowContent (line 124) | function PageHeaderSecondRowContent(props: PageHeaderSecondRowProps) {
  function PageBody (line 133) | function PageBody(props: { children: React.ReactNode }) {
  function PageAction (line 137) | function PageAction(props: {

FILE: src/client/api.ts
  function post (line 3) | async function post<T>(pathname: string, body?: any, params?: Record<str...
  function createServerError (line 21) | async function createServerError(res: Response): Promise<ServerError> {
  function getResponseContentType (line 31) | function getResponseContentType(res: Response): string | undefined {

FILE: src/client/appStore.tsx
  function get (line 8) | function get(): t.AppStore {
  function set (line 12) | function set(newStore: t.AppStore) {
  function update (line 18) | function update(recipe: t.AppStoreRecipe) {
  function addListener (line 22) | function addListener(listener: t.AppStoreListener) {
  function removeListener (line 27) | function removeListener(listener: t.AppStoreListener) {
  function use (line 32) | function use(): t.AppStore {

FILE: src/client/appStoreActions.tsx
  function initAppStore (line 16) | async function initAppStore() {
  function setUpDemo (line 49) | async function setUpDemo() {
  function updateNotes (line 58) | async function updateNotes() {
  function reduceNotePages (line 88) | function reduceNotePages(lastItemIndex: number) {
  function updateQueueCount (line 110) | async function updateQueueCount() {
  function login (line 121) | async function login(credentials: t.UsernamePassword, opts?: { importDem...
  function signup (line 134) | async function signup(credentials: t.UsernamePassword, opts?: { importDe...
  function logout (line 155) | async function logout() {
  function clearStorage (line 179) | async function clearStorage() {
  function gotError (line 188) | function gotError(error: Error) {
  function showMessage (line 193) | function showMessage(text: string, opts?: { type?: 'info' | 'error' }) {
  function saveNote (line 207) | async function saveNote(note: t.Note, opts?: { message?: string; immedia...
  function saveNotes (line 211) | async function saveNotes(notes: t.Note[], opts?: { message?: string; imm...
  function saveNoteAndQuickUpdateNotes (line 240) | async function saveNoteAndQuickUpdateNotes(note: t.Note) {
  function toggleNoteSelection (line 255) | function toggleNoteSelection(note: t.Note) {
  function archiveNoteSelection (line 272) | async function archiveNoteSelection() {
  function unarchiveNoteSelection (line 289) | async function unarchiveNoteSelection() {
  function moveNoteSelectionUp (line 306) | async function moveNoteSelectionUp() {
  function moveNoteSelectionDown (line 310) | async function moveNoteSelectionDown() {
  function moveNoteSelectionToTop (line 314) | async function moveNoteSelectionToTop() {
  function moveNoteSelectionToBottom (line 318) | async function moveNoteSelectionToBottom() {
  function moveNoteSelection (line 322) | async function moveNoteSelection(moveFn: (ids: string[]) => Promise<void...
  function makeClientLocalUserFromServer (line 332) | async function makeClientLocalUserFromServer(
  function makeSureConsistentUserAndCookie (line 347) | async function makeSureConsistentUserAndCookie() {
  function resetUser (line 354) | async function resetUser() {
  function loggedIn (line 362) | async function loggedIn(
  function checkAppUpdate (line 381) | async function checkAppUpdate() {
  function forceCheckAppUpdate (line 395) | async function forceCheckAppUpdate() {
  function checkAppUpdateHelper (line 403) | async function checkAppUpdateHelper() {
  function requireAppUpdate (line 409) | async function requireAppUpdate() {
  function updateApp (line 416) | async function updateApp() {
  function notifyIfAppUpdated (line 425) | async function notifyIfAppUpdated() {

FILE: src/client/clientToServiceWorkerApi.ts
  function postToServiceWorker (line 4) | async function postToServiceWorker(message: ClientToServiceWorkerMessage) {

FILE: src/client/cookies.ts
  function getCookie (line 3) | function getCookie(name: string): string | undefined {
  function getUserTokenFromCookie (line 10) | function getUserTokenFromCookie(): string | undefined {
  function setUserCookies (line 14) | function setUserCookies(token: string) {

FILE: src/client/cross-context-broadcast.ts
  type Listener (line 4) | type Listener = (msg: BroadcastChannelMessage) => any;
  function init (line 9) | function init() {
  function addListener (line 26) | function addListener(listener: Listener) {
  function removeListener (line 30) | function removeListener(listener: Listener) {
  function broadcast (line 38) | function broadcast(message: Omit<BroadcastChannelMessage, 'unforgetConte...

FILE: src/client/crypto.ts
  function calcClientPasswordHash (line 8) | async function calcClientPasswordHash({ username, password }: t.Username...
  function generateEncryptionSalt (line 16) | function generateEncryptionSalt(): Uint8Array<ArrayBuffer> {
  function generateIV (line 20) | function generateIV(): Uint8Array<ArrayBuffer> {
  function bytesToBase64 (line 24) | async function bytesToBase64(buffer: ArrayBuffer): Promise<string> {
  function base64ToBytes (line 36) | async function base64ToBytes(base64: string): Promise<ArrayBuffer> {
  function makeEncryptionKey (line 47) | async function makeEncryptionKey(password: string, salt: string): Promis...
  function exportEncryptionKey (line 66) | async function exportEncryptionKey(key: CryptoKey): Promise<JsonWebKey> {
  function importEncryptionKey (line 70) | async function importEncryptionKey(key: JsonWebKey): Promise<CryptoKey> {
  function encrypt (line 74) | async function encrypt(data: BufferSource, key: CryptoKey): Promise<t.En...
  function decrypt (line 81) | async function decrypt(data: t.EncryptedData, key: CryptoKey): Promise<A...
  function encryptNotes (line 87) | async function encryptNotes(notes: t.Note[], key: CryptoKey): Promise<t....
  function encryptNote (line 97) | async function encryptNote(note: t.Note, key: CryptoKey): Promise<t.Encr...
  function decryptNotes (line 103) | async function decryptNotes(notes: t.EncryptedNote[], key: CryptoKey): P...
  function decryptNote (line 113) | async function decryptNote(note: t.EncryptedNote, key: CryptoKey): Promi...

FILE: src/client/custom.d.ts
  type Window (line 18) | interface Window {

FILE: src/client/hooks.tsx
  function useInterval (line 5) | function useInterval(cb: () => void, ms: number) {
  function useCallbackCancelEvent (line 12) | function useCallbackCancelEvent(cb: () => any, deps: React.DependencyLis...
  function useClickWithoutDrag (line 22) | function useClickWithoutDrag(cb: React.MouseEventHandler): {
  function useStoreAndRestoreScrollY (line 44) | function useStoreAndRestoreScrollY() {

FILE: src/client/index.tsx
  function setup (line 16) | async function setup() {
  function registerServiceWorker (line 98) | async function registerServiceWorker() {
  function handleServiceWorkerMessage (line 124) | async function handleServiceWorkerMessage(message: t.ServiceWorkerToClie...
  function handleSyncEvent (line 144) | async function handleSyncEvent(event: SyncEvent) {
  function handleBroadcastMessage (line 169) | function handleBroadcastMessage(message: t.BroadcastChannelMessage) {

FILE: src/client/logger.ts
  function log (line 3) | function log(...args: any[]) {
  function stringify (line 19) | function stringify(...args: any[]): string {

FILE: src/client/router.tsx
  type HistoryState (line 12) | type HistoryState = { index: number; scrollY?: number };
  type Route (line 14) | type Route = {
  type Loader (line 20) | type Loader = (match: RouteMatch) => Promise<any>;
  type RouterCtxType (line 22) | type RouterCtxType = {
  type RouterLoadingCtxType (line 30) | type RouterLoadingCtxType = {
  type Params (line 34) | type Params = Record<string, string>;
  type RouteMatch (line 37) | type RouteMatch = { route: Route; params: Params; pathname: string };
  type WrappedPromise (line 39) | type WrappedPromise<T> = { read: () => T; status: 'pending' | 'success' ...
  function Router (line 45) | function Router(props: { routes: Route[]; fallback: React.ReactNode }) {
  function Link (line 80) | function Link(props: { to: string; className?: string; children: React.R...
  function Suspender (line 96) | function Suspender(props: { children: Route['element'] }) {
  function useRouterLoading (line 106) | function useRouterLoading(): RouterLoadingCtxType {
  function useRouter (line 110) | function useRouter(): RouterCtxType {
  function matchRoute (line 114) | function matchRoute(pathname: string, routes: Route[]): RouteMatch | und...
  function matchParts (line 123) | function matchParts(actualParts: string[], expectedParts: string[]): Par...
  function getLocationPathname (line 147) | function getLocationPathname(): string {
  function getLocationSearch (line 152) | function getLocationSearch(): string {
  function getHistoryState (line 157) | function getHistoryState(): HistoryState {
  function subscribeToHistoryUpdates (line 161) | function subscribeToHistoryUpdates(callback: () => void) {
  function wrapPromise (line 172) | function wrapPromise<T>(promise: Promise<T>): WrappedPromise<T> {
  function assertHistoryStateType (line 199) | function assertHistoryStateType(data: any) {
  function setUpManualScrollRestoration (line 212) | function setUpManualScrollRestoration() {
  function storeScrollY (line 216) | function storeScrollY() {
  function patchHistory (line 226) | function patchHistory() {

FILE: src/client/serviceWorker.ts
  constant CACHE_NAME (line 12) | const CACHE_NAME = `unforget-${CACHE_VERSION}`;
  constant APP_STATIC_RESOURCES (line 13) | const APP_STATIC_RESOURCES = ['/', '/style.css', '/index.js', '/manifest...
  function installServiceWorker (line 47) | async function installServiceWorker() {
  function activateServiceWorker (line 58) | async function activateServiceWorker() {
  function handleFetch (line 83) | async function handleFetch(event: FetchEvent): Promise<Response> {
  function handleClientMessage (line 123) | async function handleClientMessage(client: Client, message: ClientToServ...

FILE: src/client/serviceWorkerToClientApi.ts
  function postToClient (line 7) | function postToClient(client: Client, message: ServiceWorkerToClientMess...
  function postToClients (line 11) | async function postToClients(message: ServiceWorkerToClientMessage, opti...

FILE: src/client/storage.ts
  constant DB_NAME (line 9) | const DB_NAME = 'unforget';
  constant NOTES_STORE (line 10) | const NOTES_STORE = 'notes';
  constant NOTES_STORE_ORDER_INDEX (line 11) | const NOTES_STORE_ORDER_INDEX = 'orderIndex';
  constant NOTES_QUEUE_STORE (line 12) | const NOTES_QUEUE_STORE = 'notesQueue';
  constant SETTINGS_STORE (line 13) | const SETTINGS_STORE = 'settings';
  type SaveNoteQueueItem (line 15) | type SaveNoteQueueItem = { note: t.Note; resolve: () => void; reject: (e...
  function getStorage (line 20) | async function getStorage(): Promise<IDBDatabase> {
  function transaction (line 70) | async function transaction<T>(
  function saveNote (line 102) | async function saveNote(note: t.Note) {
  function saveNotes (line 106) | async function saveNotes(notes: t.Note[]) {
  function enqueueNote (line 112) | function enqueueNote(note: t.Note): Promise<void> {
  function saveNextNotesInQueue (line 118) | async function saveNextNotesInQueue() {
  function saveNoteQueueItems (line 140) | async function saveNoteQueueItems(items: SaveNoteQueueItem[]) {
  function createNoteHeadFromNote (line 149) | function createNoteHeadFromNote(note: t.Note): t.NoteHead {
  function moveNotesUp (line 153) | async function moveNotesUp(ids: string[]) {
  function moveNotesDown (line 197) | async function moveNotesDown(ids: string[]) {
  function moveNotesToTop (line 242) | async function moveNotesToTop(ids: string[]) {
  function moveNotesToBottom (line 280) | async function moveNotesToBottom(ids: string[]) {
  function isSavingNote (line 317) | function isSavingNote(): boolean {
  function getAllNotes (line 321) | async function getAllNotes(): Promise<t.Note[]> {
  function getNotes (line 330) | async function getNotes(opts?: {
  function getNotesById (line 413) | async function getNotesById(ids: string[]): Promise<(t.Note | undefined)...
  function getNote (line 421) | async function getNote(id: string): Promise<t.Note | undefined> {
  function clearAll (line 425) | async function clearAll() {
  function waitForDBRequest (line 440) | async function waitForDBRequest<T>(req: IDBRequest<T>): Promise<T> {
  function countQueuedNotes (line 451) | async function countQueuedNotes(): Promise<number> {
  function getSetting (line 456) | async function getSetting<T = unknown>(key: string): Promise<T | undefin...
  function setSetting (line 463) | async function setSetting(value: any, key: string) {
  function setUser (line 475) | async function setUser(user: t.ClientLocalUser) {
  function getUser (line 480) | async function getUser(): Promise<t.ClientLocalUser | undefined> {
  function clearUser (line 487) | async function clearUser() {

FILE: src/client/sync.ts
  type SyncEvent (line 12) | type SyncEvent =
  type SyncListener (line 17) | type SyncListener = (event: SyncEvent) => any;
  function callSyncListeners (line 25) | function callSyncListeners(event: SyncEvent) {
  function addSyncEventListener (line 29) | function addSyncEventListener(listener: SyncListener) {
  function removeSyncEventListener (line 33) | function removeSyncEventListener(listener: SyncListener) {
  function syncInInterval (line 38) | function syncInInterval() {
  function sync (line 45) | async function sync() {
  function isSyncing (line 138) | function isSyncing(): boolean {
  function getDeltaSyncData (line 142) | async function getDeltaSyncData(user: t.ClientLocalUser): Promise<t.Sync...
  function getQueueSyncData (line 162) | async function getQueueSyncData(): Promise<t.SyncHeadsData> {
  function mergeSyncData (line 179) | async function mergeSyncData(
  function mergeSyncHeadsData (line 246) | async function mergeSyncHeadsData(reqSyncHeadsData: t.SyncHeadsData, res...
  function requireQueueSync (line 279) | function requireQueueSync() {

FILE: src/common/mdFns.ts
  type Range (line 11) | type Range = { start: number; end: number };
  type ListItem (line 12) | type ListItem = {
  function isCursorOnCheckbox (line 36) | function isCursorOnCheckbox(l: ListItem, i: number) {
  function toggleListItemCheckbox (line 41) | function toggleListItemCheckbox(l: ListItem): ListItem {
  function setListItemCheckbox (line 45) | function setListItemCheckbox(l: ListItem, checked: boolean): ListItem {
  function removeListItemCheckbox (line 49) | function removeListItemCheckbox(l: ListItem): ListItem {
  function addListItemCheckbox (line 54) | function addListItemCheckbox(l: ListItem): ListItem {
  function removeListItemType (line 60) | function removeListItemType(l: ListItem): ListItem {
  function incrementListItemNumber (line 66) | function incrementListItemNumber(l: ListItem): ListItem {
  function parseListItem (line 75) | function parseListItem(line: string): ListItem {
  function getListItemCheckboxRange (line 84) | function getListItemCheckboxRange(l: ListItem): Range {
  function stringifyListItem (line 89) | function stringifyListItem(l: ListItem): string {
  function stringifyListItemPrefix (line 93) | function stringifyListItemPrefix(l: ListItem): string {
  function insertText (line 97) | function insertText(text: string, segment: string, range: Range): string {
  function getLineRangeAt (line 101) | function getLineRangeAt(text: string, i: number): Range {
  function getLine (line 105) | function getLine(text: string, range: Range): string {
  function getLineStart (line 109) | function getLineStart(text: string, i: number): number {
  function getLineEnd (line 114) | function getLineEnd(text: string, i: number): number {
  function skipWhitespaceSameLine (line 119) | function skipWhitespaceSameLine(text: string, i: number): number {

FILE: src/common/types.ts
  type Note (line 3) | type Note = {
  type EncryptedNote (line 30) | type EncryptedNote = EncryptedData & {
  type EncryptedData (line 35) | type EncryptedData = {
  type DBEncryptedNote (line 43) | type DBEncryptedNote = EncryptedNote & {
  type DBUser (line 47) | type DBUser = {
  type DBClient (line 54) | type DBClient = {
  type SignupData (line 61) | type SignupData = {
  type LoginData (line 67) | type LoginData = {
  type LoginResponse (line 72) | type LoginResponse = {
  type UsernamePassword (line 78) | type UsernamePassword = {
  type SyncData (line 83) | type SyncData = {
  type SyncHeadsData (line 88) | type SyncHeadsData = {
  type NoteHead (line 93) | type NoteHead = {
  type DBNoteHead (line 98) | type DBNoteHead = NoteHead & {
  type DeltaSyncReq (line 102) | type DeltaSyncReq = SyncData;
  type DeltaSyncResNormal (line 104) | type DeltaSyncResNormal = {
  type DeltaSyncResRequireQueueSync (line 108) | type DeltaSyncResRequireQueueSync = {
  type DeltaSyncRes (line 112) | type DeltaSyncRes = DeltaSyncResNormal | DeltaSyncResRequireQueueSync;
  type QueueSyncReq (line 114) | type QueueSyncReq = SyncHeadsData;
  type QueueSyncRes (line 116) | type QueueSyncRes = SyncHeadsData;
  type ServerConfig (line 118) | type ServerConfig = {
  type ServerUserClient (line 122) | type ServerUserClient = {
  type ClientLocalUser (line 127) | type ClientLocalUser = {
  type AppStore (line 133) | type AppStore = {
  type AppStoreRecipe (line 153) | type AppStoreRecipe = (draft: Draft<AppStore>) => AppStore | void;
  type AppStoreListener (line 154) | type AppStoreListener = (newStore: AppStore, oldStore: AppStore) => void;
  type ParsedLine (line 156) | type ParsedLine = {
  type ServerErrorJSON (line 170) | type ServerErrorJSON = {
  type ServerErrorType (line 176) | type ServerErrorType = 'app_requires_update' | 'generic';
  type HistoryState (line 178) | type HistoryState = {
  type ClientToServiceWorkerMessage (line 182) | type ClientToServiceWorkerMessage = void;
  type ServiceWorkerToClientMessage (line 190) | type ServiceWorkerToClientMessage =
  type BroadcastChannelMessage (line 198) | type BroadcastChannelMessage =

FILE: src/common/util.ts
  constant CACHE_VERSION (line 4) | const CACHE_VERSION = 188;
  function assert (line 6) | function assert(condition: any, message: string): asserts condition {
  function isNoteNewerThan (line 10) | function isNoteNewerThan(a: t.NoteHead, b?: t.NoteHead): boolean {
  function escapeRegExp (line 15) | function escapeRegExp(str: string): string {
  function calcNewSelection (line 89) | function calcNewSelection(
  function bytesToHexString (line 103) | function bytesToHexString(bytes: Uint8Array<ArrayBuffer>): string {
  function hexStringToBytes (line 109) | function hexStringToBytes(str: string): BufferSource {
  class ServerError (line 119) | class ServerError extends Error {
    method constructor (line 120) | constructor(
    method fromJSON (line 128) | static fromJSON(json: any): ServerError {
    method toJSON (line 132) | toJSON() {
  function createNewNote (line 137) | function createNewNote(text: string): t.Note {
  function formatDateTime (line 152) | function formatDateTime(date: Date) {

FILE: src/server/db.ts
  function initDB (line 9) | function initDB() {
  function get (line 66) | function get(): Database.Database {
  function getSyncNumber (line 70) | function getSyncNumber(client: t.ServerUserClient) {
  function getQueuedNotes (line 74) | function getQueuedNotes(client: t.ServerUserClient): t.EncryptedNote[] {
  function getQueuedNoteHeads (line 81) | function getQueuedNoteHeads(client: t.ServerUserClient): t.NoteHead[] {
  function getNotes (line 85) | function getNotes(client: t.ServerUserClient, ids?: string[]): t.Encrypt...
  function getNoteHeads (line 98) | function getNoteHeads(client: t.ServerUserClient): t.NoteHead[] {
  function dbNoteToNote (line 102) | function dbNoteToNote(dbNote: t.DBEncryptedNote): t.EncryptedNote {
  function logout (line 106) | function logout(token: string) {
  function mergeSyncData (line 116) | function mergeSyncData(
  function mergeSyncHeadsData (line 181) | function mergeSyncHeadsData(
  function importNotes (line 229) | function importNotes(username: string, notes: t.EncryptedNote[]) {
  function prepareInsertIntoQueue (line 263) | function prepareInsertIntoQueue(): Statement<[t.DBNoteHead]> {
  function preparePutNote (line 273) | function preparePutNote(): Statement<[t.DBEncryptedNote]> {

FILE: src/server/index.ts
  constant PUBLIC (line 13) | const PUBLIC = path.join(process.cwd(), 'public');
  constant DIST_PUBLIC (line 14) | const DIST_PUBLIC = path.join(process.cwd(), 'dist/public');
  type Locals (line 20) | interface Locals {
  function loginAndRespond (line 138) | function loginAndRespond(user: t.DBUser, res: express.Response) {
  function calcDoublePasswordHash (line 282) | async function calcDoublePasswordHash(password_client_hash: string, pass...
  function computeSHA256 (line 287) | async function computeSHA256(data: Uint8Array): Promise<string> {
  function authenticate (line 292) | function authenticate(_req: express.Request, res: express.Response, next...
  function generateRandomCryptoString (line 300) | function generateRandomCryptoString(): string {
  function log (line 306) | function log(res: express.Response, ...args: any[]) {
  function logDebug (line 310) | function logDebug(res: express.Response, ...args: any[]) {
  function logError (line 316) | function logError(res: express.Response, ...args: any[]) {
  function getClientStr (line 320) | function getClientStr(res: express.Response): string {

FILE: src/server/validateEnvVars.ts
  type KeyType (line 10) | type KeyType = (typeof keys)[number];
  type ProcessEnv (line 14) | interface ProcessEnv extends Record<KeyType, string> {}
Condensed preview — 64 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (259K chars).
[
  {
    "path": ".gitignore",
    "chars": 89,
    "preview": "node_modules\ndist\nprivate\ntsconfig.tsbuildinfo\n.env\ndeploy\n*#\n/examples/credentials.json\n"
  },
  {
    "path": ".ignore",
    "chars": 17,
    "preview": "package-lock.json"
  },
  {
    "path": ".prettierrc",
    "chars": 209,
    "preview": "{\n  \"trailingComma\": \"all\",\n  \"bracketSpacing\": true,\n  \"printWidth\": 120,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"semi\""
  },
  {
    "path": "LICENSE.txt",
    "chars": 1084,
    "preview": "Copyright (c) 2024 Computing Den, https://computing-den.com\n\nPermission is hereby granted, free of charge, to any person"
  },
  {
    "path": "README.md",
    "chars": 12404,
    "preview": "# Unforget\n\n![screenshot](doc/screenshots.png)\n\n*Start now without registering at [unforget.computing-den.com](https://u"
  },
  {
    "path": "esbuild.config.mjs",
    "chars": 961,
    "preview": "import 'dotenv/config';\nimport esbuild from 'esbuild';\n\nconst context = await esbuild.context({\n  entryPoints: ['src/cli"
  },
  {
    "path": "examples/example.py",
    "chars": 9247,
    "preview": "# The following was generated by ChatGPT4o from the original example.ts with some trial and error.\n# It was manually ins"
  },
  {
    "path": "examples/example.ts",
    "chars": 9592,
    "preview": "import { webcrypto } from 'node:crypto';\nimport fs from 'node:fs';\n\ntype Note = {\n  // UUID version 4\n  id: string;\n\n  /"
  },
  {
    "path": "package.json",
    "chars": 2084,
    "preview": "{\n  \"name\": \"unforget\",\n  \"title\": \"Unforget\",\n  \"version\": \"1.0.0\",\n  \"description\": \"A minimalist, offline-first, and "
  },
  {
    "path": "public/manifest.json",
    "chars": 388,
    "preview": "{\n  \"name\": \"Unforget\",\n  \"short_name\": \"Unforget\",\n  \"description\": \"Never forget a thing.\",\n  \"theme_color\": \"#448199\""
  },
  {
    "path": "scripts/script.ts",
    "chars": 50,
    "preview": "let hello: string = 'Hello';\n\nconsole.log(hello);\n"
  },
  {
    "path": "scripts/tsconfig.json",
    "chars": 50,
    "preview": "{\n  \"extends\": \"@tsconfig/node18/tsconfig.json\"\n}\n"
  },
  {
    "path": "src/client/AboutPage.tsx",
    "chars": 778,
    "preview": "import React from 'react';\nimport * as appStore from './appStore.jsx';\nimport { createNewNote, CACHE_VERSION } from '../"
  },
  {
    "path": "src/client/App.tsx",
    "chars": 2250,
    "preview": "import { Router, Route, useRouter } from './router.jsx';\nimport React, { useEffect } from 'react';\nimport * as appStore "
  },
  {
    "path": "src/client/DemoPage.tsx",
    "chars": 392,
    "preview": "import * as appStore from './appStore.js';\nimport * as actions from './appStoreActions.jsx';\nimport _ from 'lodash';\n\nfu"
  },
  {
    "path": "src/client/Editor.css",
    "chars": 357,
    "preview": "textarea.editor {\n  /* background: #eee; */\n  padding: 1rem 1rem;\n  /* margin: 5rem; */\n  /* width: 100px; */\n  font-fam"
  },
  {
    "path": "src/client/Editor.tsx",
    "chars": 9240,
    "preview": "import log from './logger.js';\nimport React, { useState, useLayoutEffect, useRef, forwardRef, useImperativeHandle } from"
  },
  {
    "path": "src/client/ExportPage.tsx",
    "chars": 1405,
    "preview": "import React, { useCallback, useState, useEffect, useRef } from 'react';\nimport type * as t from '../common/types.js';\ni"
  },
  {
    "path": "src/client/ImportPage.tsx",
    "chars": 7609,
    "preview": "import React, { useState } from 'react';\nimport type * as t from '../common/types.js';\nimport { createNewNote, assert } "
  },
  {
    "path": "src/client/LoginPage.css",
    "chars": 934,
    "preview": ".login-page {\n  max-width: 300px;\n\n  .form-element {\n    display: flex;\n    flex-direction: column;\n\n    & + .form-eleme"
  },
  {
    "path": "src/client/LoginPage.tsx",
    "chars": 4554,
    "preview": "import { useRouter } from './router.jsx';\nimport React, { useEffect, useState } from 'react';\nimport * as appStore from "
  },
  {
    "path": "src/client/Menu.css",
    "chars": 1368,
    "preview": ".menu {\n  position: absolute;\n  top: 28px;\n  background: #ffffff;\n  /* backdrop-filter: blur(5px); */\n  z-index: var(--m"
  },
  {
    "path": "src/client/Menu.tsx",
    "chars": 1950,
    "preview": "import { useRouter } from './router.jsx';\nimport React, { useState, useEffect, useRef } from 'react';\n\nexport type MenuI"
  },
  {
    "path": "src/client/NotePage.css",
    "chars": 380,
    "preview": ".note-page {\n  margin-bottom: 0;\n\n  .note-container {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    po"
  },
  {
    "path": "src/client/NotePage.tsx",
    "chars": 7775,
    "preview": "import { useRouter, RouteMatch } from './router.jsx';\nimport React, { useCallback, useState, useEffect, useRef } from 'r"
  },
  {
    "path": "src/client/Notes.css",
    "chars": 7102,
    "preview": ".notes {\n  border: 1px solid var(--box-border-color);\n  border-radius: var(--box-border-radius);\n  overflow: hidden;\n\n  "
  },
  {
    "path": "src/client/Notes.tsx",
    "chars": 7769,
    "preview": "import React, { memo, useState } from 'react';\nimport type * as t from '../common/types.js';\nimport { assert } from '../"
  },
  {
    "path": "src/client/NotesPage.css",
    "chars": 1707,
    "preview": ".notes-page {\n  .new-note-container {\n    display: flex;\n    flex-direction: column;\n    transition: 0.15s transform eas"
  },
  {
    "path": "src/client/NotesPage.tsx",
    "chars": 15574,
    "preview": "import { RouteMatch } from './router.jsx';\nimport React, { useState, useEffect, useRef, memo } from 'react';\nimport { us"
  },
  {
    "path": "src/client/Notifications.css",
    "chars": 1466,
    "preview": ".msg-bar {\n  position: fixed;\n  bottom: 3rem;\n  /* left: 0; */\n  /* right: 0; */\n  /* width: 100%; */\n  /* border-top: 1"
  },
  {
    "path": "src/client/Notifications.tsx",
    "chars": 682,
    "preview": "import * as actions from './appStoreActions.jsx';\nimport * as appStore from './appStore.js';\nimport React from 'react';\n"
  },
  {
    "path": "src/client/PageLayout.css",
    "chars": 4454,
    "preview": "#page-header {\n  z-index: var(--page-header-z-index);\n  background: var(--page-header-background);\n  position: fixed;\n  "
  },
  {
    "path": "src/client/PageLayout.tsx",
    "chars": 5560,
    "preview": "import { useRouter } from './router.jsx';\nimport React, { useCallback, useState } from 'react';\nimport { useCallbackCanc"
  },
  {
    "path": "src/client/api.ts",
    "chars": 1233,
    "preview": "import { ServerError, CACHE_VERSION } from '../common/util.js';\n\nexport async function post<T>(pathname: string, body?: "
  },
  {
    "path": "src/client/appStore.tsx",
    "chars": 957,
    "preview": "import { produce } from 'immer';\nimport { useSyncExternalStore } from 'react';\nimport type * as t from '../common/types."
  },
  {
    "path": "src/client/appStoreActions.tsx",
    "chars": 12957,
    "preview": "import type * as t from '../common/types.js';\nimport * as storage from './storage.js';\nimport * as appStore from './appS"
  },
  {
    "path": "src/client/clientToServiceWorkerApi.ts",
    "chars": 336,
    "preview": "import type { ClientToServiceWorkerMessage } from '../common/types.js';\nimport log from './logger.js';\n\nexport async fun"
  },
  {
    "path": "src/client/common.css",
    "chars": 4545,
    "preview": ":root {\n  --app-background: #ddf7ff;\n  --page-header-height: 2rem;\n  --page-header-second-row-height: 2rem;\n  --page-hea"
  },
  {
    "path": "src/client/cookies.ts",
    "chars": 495,
    "preview": "import log from './logger.js';\n\nexport function getCookie(name: string): string | undefined {\n  return document.cookie\n "
  },
  {
    "path": "src/client/cross-context-broadcast.ts",
    "chars": 1160,
    "preview": "import type { BroadcastChannelMessage } from '../common/types.js';\nimport log from './logger.js';\n\nexport type Listener "
  },
  {
    "path": "src/client/crypto.ts",
    "chars": 4280,
    "preview": "import type * as t from '../common/types.js';\nimport { bytesToHexString, hexStringToBytes } from '../common/util.js';\nim"
  },
  {
    "path": "src/client/custom.d.ts",
    "chars": 424,
    "preview": "declare module '*.svg' {\n  const content: string;\n  export default content;\n}\ndeclare module '*.txt' {\n  const content: "
  },
  {
    "path": "src/client/hooks.tsx",
    "chars": 1733,
    "preview": "import { useRouter, storeScrollY } from './router.jsx';\nimport React, { useCallback, useState, useEffect, useLayoutEffec"
  },
  {
    "path": "src/client/icons.ts",
    "chars": 3026,
    "preview": "export { default as addWhite } from '../../public/icons/add-white.svg';\nexport { default as archiveEmpty } from '../../p"
  },
  {
    "path": "src/client/index.tsx",
    "chars": 5377,
    "preview": "import type * as t from '../common/types.js';\nimport { createRoot } from 'react-dom/client';\nimport * as storage from '."
  },
  {
    "path": "src/client/logger.ts",
    "chars": 779,
    "preview": "import * as api from './api.js';\n\nfunction log(...args: any[]) {\n  if (Number(process.env.LOG_TO_CONSOLE)) {\n    console"
  },
  {
    "path": "src/client/normalize.css",
    "chars": 6142,
    "preview": "/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n   ==========================="
  },
  {
    "path": "src/client/notes/about.md",
    "chars": 1321,
    "preview": "# About\n\nUnforget is made by [Computing Den](https://computing-den.com), a software company specializing in web technolo"
  },
  {
    "path": "src/client/notes/export.md",
    "chars": 705,
    "preview": "# Export as JSON\n\n[Click here](#export-json) to export notes in JSON format.\n\nThe JSON file will contain an array of not"
  },
  {
    "path": "src/client/notes/import.md",
    "chars": 1152,
    "preview": "\n*Note: the import process is done entirely on your device to preserve your privacy.*\n\n# Google Keep\n\n1. Go to [Google T"
  },
  {
    "path": "src/client/notes/welcome1.md",
    "chars": 3535,
    "preview": "# Welcome!\n\nUnforget is a minimalist, offline-first, and end-to-end encrypted note-taking app (without Electron.js) feat"
  },
  {
    "path": "src/client/router.tsx",
    "chars": 8226,
    "preview": "import React, {\n  useMemo,\n  useCallback,\n  useContext,\n  createContext,\n  useDeferredValue,\n  useSyncExternalStore,\n  S"
  },
  {
    "path": "src/client/serviceWorker.ts",
    "chars": 4399,
    "preview": "// Default type of `self` is `WorkerGlobalScope & typeof globalThis`\n// https://github.com/microsoft/TypeScript/issues/1"
  },
  {
    "path": "src/client/serviceWorkerToClientApi.ts",
    "chars": 768,
    "preview": "// Default type of `self` is `WorkerGlobalScope & typeof globalThis`\n// https://github.com/microsoft/TypeScript/issues/1"
  },
  {
    "path": "src/client/storage.ts",
    "chars": 17228,
    "preview": "import type * as t from '../common/types.js';\nimport * as cutil from '../common/util.jsx';\nimport { exportEncryptionKey,"
  },
  {
    "path": "src/client/style.css",
    "chars": 260,
    "preview": "@import './normalize.css';\n@import './common.css';\n@import './PageLayout.css';\n@import './Editor.css';\n@import './Menu.c"
  },
  {
    "path": "src/client/sync.ts",
    "chars": 10373,
    "preview": "declare var self: ServiceWorkerGlobalScope;\n\nimport type * as t from '../common/types.js';\nimport { ServerError, isNoteN"
  },
  {
    "path": "src/common/mdFns.ts",
    "chars": 4223,
    "preview": "import _ from 'lodash';\n\n// space type space checkbox space content\nconst ulRegExp =\n  /^(?<space1>\\ *)((?<type>[\\*+-])("
  },
  {
    "path": "src/common/types.ts",
    "chars": 4338,
    "preview": "import type { Draft } from 'immer';\n\nexport type Note = {\n  // UUID version 4\n  id: string;\n\n  // Deleted notes have nul"
  },
  {
    "path": "src/common/util.ts",
    "chars": 5471,
    "preview": "import type * as t from './types.js';\nimport { v4 as uuid } from 'uuid';\n\nexport const CACHE_VERSION = 188;\n\nexport func"
  },
  {
    "path": "src/server/db.ts",
    "chars": 10077,
    "preview": "import type * as t from '../common/types.js';\nimport * as cutil from '../common/util.js';\nimport Database, { Statement }"
  },
  {
    "path": "src/server/index.ts",
    "chars": 10432,
    "preview": "import 'dotenv/config';\nimport './validateEnvVars.js';\n\nimport express from 'express';\nimport path from 'node:path';\nimp"
  },
  {
    "path": "src/server/validateEnvVars.ts",
    "chars": 482,
    "preview": "const keys = [\n  'PORT',\n  'NODE_ENV',\n  'DISABLE_CACHE',\n  'LOG_TO_CONSOLE',\n  'FORWARD_LOGS_TO_SERVER',\n  'FORWARD_ERR"
  },
  {
    "path": "tsconfig.json",
    "chars": 1363,
    "preview": "{\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\",\n    \"module\": \"nodenext\", // Depends on moduleResol"
  }
]

About this extraction

This page contains the full source code of the computing-den/unforget GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 64 files (241.5 KB), approximately 63.3k tokens, and a symbol index with 349 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!