mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge lab into feat/admin-build-provenance
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Odysseus UI — Environment Configuration
|
||||
# Copy this file to .env and fill in your values.
|
||||
#
|
||||
# This file stays deliberately short: it is for deployment-level overrides, and
|
||||
# most runtime configuration belongs in Settings inside the app. For the complete
|
||||
# list of ODYSSEUS_* variables the code reads, with the default each one falls
|
||||
# back to, see website/configuration-reference.md (generated from the source).
|
||||
|
||||
# ============================================================
|
||||
# LLM Configuration
|
||||
|
||||
@@ -316,6 +316,7 @@ if AUTH_ENABLED:
|
||||
|
||||
def _refresh_token_cache():
|
||||
"""Rebuild the prefix→[(id,hash)] map from the DB."""
|
||||
global _token_cache
|
||||
from collections import defaultdict
|
||||
new_map = defaultdict(list)
|
||||
db = SessionLocal()
|
||||
@@ -334,8 +335,8 @@ if AUTH_ENABLED:
|
||||
new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes))
|
||||
finally:
|
||||
db.close()
|
||||
_token_cache.clear()
|
||||
_token_cache.update(new_map)
|
||||
_token_cache = dict(new_map)
|
||||
app.state._token_cache = _token_cache
|
||||
app.state._token_cache_dirty = False
|
||||
|
||||
# Headers that prove a request was forwarded by a proxy/tunnel (cloudflared,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Ref parity audit
|
||||
|
||||
`scripts/ref_parity_audit.py` reports which commits on one git ref left no trace
|
||||
in another, and which files exist on one and not the other. It is read-only: it
|
||||
runs `git log`, `git show`, `git diff`, `git grep`, `git ls-tree` and
|
||||
`git merge-base`, writes nothing to the repository, touches no remote, and does
|
||||
not import the application package.
|
||||
|
||||
## Why it exists
|
||||
|
||||
`lab` and the public `dev` line share only the repository's first commit as a
|
||||
merge base, so `git log lab..dev` lists thousands of commits — nearly all of
|
||||
which are in fact present on both sides, having arrived under different SHAs. A
|
||||
plain log tells you nothing about what is actually missing.
|
||||
|
||||
The question that matters before `lab` becomes a release is narrower: is there a
|
||||
fix on the public line that never reached `lab`? This script answers that by
|
||||
sampling distinctive added lines from each commit and searching the other tree
|
||||
for them.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
git remote add public https://github.com/odysseus-dev/odysseus.git # once
|
||||
git fetch public dev --no-tags
|
||||
|
||||
scripts/ref_parity_audit.py --source public/dev --target lab --since 2026-08-10
|
||||
```
|
||||
|
||||
Roughly 30 seconds for a 100-commit window; it grows linearly, so bound a wide
|
||||
audit with `--since`. Add `--format json` for a machine-readable report and
|
||||
`--output PATH` to write it to a file.
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--source REF` | The ref whose commits are audited. Required. |
|
||||
| `--target REF` | The ref searched for traces of them. Required. |
|
||||
| `--since` / `--until` | Bound the commit range. Both filter **committer** date, which is also the date the report prints. |
|
||||
| `--traversal linear` | Default. Individual authored commits, merges dropped. Finds a fix that arrived on a side branch. |
|
||||
| `--traversal first-parent` | One row per merge into the source branch, which reads as one row per merged pull request. |
|
||||
| `--probes N` | Probe lines sampled per commit, default 4. |
|
||||
| `--exclude GLOB` | Extra path glob whose lines are not used as probes. Repeatable. |
|
||||
| `--no-default-excludes` | Drop the built-in vendored / lockfile / binary exclusions. |
|
||||
| `--top N` | Rows shown per file list, default 50. |
|
||||
| `--repo PATH` | Repository to run in. Defaults to this checkout. |
|
||||
|
||||
## How a verdict is reached
|
||||
|
||||
For each commit in `target..source`, the script takes the patch with no context
|
||||
lines, collects the added lines, drops the ones from vendored code, committed
|
||||
build output, lockfiles and binaries, and keeps those that are at least 24
|
||||
characters long and name at least two distinct identifiers. It ranks what is
|
||||
left by how many distinct identifiers each line carries (length breaks ties),
|
||||
takes the top `--probes`, and searches the whole target tree for each one with
|
||||
`git grep --fixed-strings`.
|
||||
|
||||
Probes are stripped of leading and trailing whitespace, so a change that was
|
||||
re-indented on the target still counts as present. The whole target tree is
|
||||
searched, not the same file, because a ported fix routinely moves.
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| **absent** | No probe found anywhere in the target. Treat as a real gap and read the diff. |
|
||||
| **partial** | Some probes found. **Inconclusive.** A line can be rewritten by a refactor on the target and still be the same change. |
|
||||
| **present** | Every probe found. The change is almost certainly there in some form. |
|
||||
| **no-probe** | Nothing to sample: a deletion-only commit, or one touching only excluded paths. No verdict. |
|
||||
|
||||
## What is exact and what is a heuristic
|
||||
|
||||
**Exact:** the two file-presence lists. They come from `git ls-tree` on both
|
||||
refs, so a file in "on the source and not the target" is definitely not there.
|
||||
|
||||
**Heuristic:** every commit verdict. It samples at most four lines out of a
|
||||
diff that may be hundreds, and a probe can be absent because the area was
|
||||
refactored rather than because the change was never made.
|
||||
|
||||
The two complement each other in a specific and useful way. A commit that reads
|
||||
**present** while one of the files it added shows up in the source-only list is
|
||||
almost always a fix whose production change was reproduced on the target without
|
||||
its test. The line sampling cannot see that; the presence diff can.
|
||||
|
||||
Read the diff before porting anything. The verdicts say where to look, not what
|
||||
to do.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_ref_parity_audit.py`. The end-to-end cases build a throwaway
|
||||
repository with two branches off one root, so the verdicts come from git's own
|
||||
`grep` and `diff` rather than from a fake.
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Email route domain package.
|
||||
|
||||
Contains email_routes.py, email_helpers.py and email_pollers.py, migrated
|
||||
from the flat routes/ directory. Backward-compat shims at
|
||||
routes/email_routes.py, routes/email_helpers.py and routes/email_pollers.py
|
||||
replace themselves with these modules, so both import paths resolve to one
|
||||
object.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+11
-2020
File diff suppressed because it is too large
Load Diff
+11
-1759
File diff suppressed because it is too large
Load Diff
+11
-7189
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Computed-style snapshot harness for ``static/style.css``.
|
||||
"""Computed-style snapshot harness for the shipped app CSS cascade.
|
||||
|
||||
``static/style.css`` is a single 51k-line stylesheet whose rendered result
|
||||
depends on source order: hundreds of selectors are declared more than once and
|
||||
The app CSS is an ordered multi-file cascade whose rendered result depends
|
||||
on source order: hundreds of selectors are declared more than once and
|
||||
``!important`` is used throughout. Any restructuring - extracting a block into
|
||||
its own file, reordering ``<link>`` tags, moving an ``@media`` rule - can
|
||||
silently change which declaration wins, and nothing else in the suite would
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"""odysseus-mail — Unix-style command-line wrapper around the email
|
||||
backend that powers the web UI.
|
||||
|
||||
Calls the same helpers `routes/email_helpers.py` exports, so a request
|
||||
Calls the same helpers `routes/email/email_helpers.py` exports, so a request
|
||||
issued from the shell hits IMAP/SMTP through the same connection pool
|
||||
and the same parsing pipeline as the HTTP routes. State is shared via
|
||||
`data/app.db` and `data/.app_key` (passwords decrypt automatically).
|
||||
|
||||
Executable
+209
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""odysseus-smoke — boot this worktree and drive every advertised feature area once.
|
||||
|
||||
The decomposition work has two safety nets and neither one covers the
|
||||
product: the checkpoint benchmark measures the agent runtime, and the
|
||||
computed-style snapshot pins the CSS. Nothing checked that Notes,
|
||||
Calendar, Documents, Email, Memory, Cookbook or Settings still worked
|
||||
after a route package moved or a 17,000-line module was split. This is
|
||||
that check, and it is deliberately shallow: one scenario per area,
|
||||
asserting a user-visible outcome rather than an HTTP 200.
|
||||
|
||||
It owns no instance logic. `odysseus dev` already isolates the ports,
|
||||
the data dir and ChromaDB per worktree, so this boots through it, hands
|
||||
the details to pytest in the environment, and stops what it started.
|
||||
|
||||
odysseus smoke # boot, run every area, stop again
|
||||
odysseus smoke --keep-up # leave the instance running afterwards
|
||||
odysseus smoke --no-boot # drive whatever is already up here
|
||||
odysseus smoke --restart # stop a running instance and boot fresh
|
||||
odysseus smoke --areas # print the coverage table without running
|
||||
odysseus smoke -- -k notes # everything after -- goes to pytest
|
||||
|
||||
The report is a per-area table, printed by the suite itself, listing the
|
||||
areas it does not cover next to the ones it does. An area with no
|
||||
scenario shows up as NOT RUN rather than going missing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
||||
from cli import quiet_logs, fail, common_parser, run # noqa: E402
|
||||
|
||||
quiet_logs()
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPTS_DIR.parent
|
||||
|
||||
# The launcher this tool delegates every instance decision to.
|
||||
DEV_SCRIPT = "odysseus-dev"
|
||||
|
||||
# What pytest is pointed at, relative to the checkout root.
|
||||
SMOKE_SUITE = "tests/smoke"
|
||||
|
||||
# Email is the one area with no reachable real backend, and the repo
|
||||
# already has a deterministic path for it. Turning it on is the reason
|
||||
# this tool owns the boot rather than leaving it to the caller: the flag
|
||||
# is read inside the app's process, so it has to be in the environment
|
||||
# the app is started with.
|
||||
EMAIL_FIXTURE_ENV = "ODYSSEUS_EMAIL_FIXTURE"
|
||||
|
||||
|
||||
def load_dev():
|
||||
"""Import `scripts/odysseus-dev` as a module.
|
||||
|
||||
Same loader the CLI tests use. Delegating by import rather than by
|
||||
parsing `odysseus dev env` output means the port derivation and the
|
||||
credential handling have exactly one implementation.
|
||||
"""
|
||||
path = SCRIPTS_DIR / DEV_SCRIPT
|
||||
if not path.exists():
|
||||
fail(f"{path} is missing; this tool boots through it.", code=2)
|
||||
loader = importlib.machinery.SourceFileLoader("odysseus_dev_cli", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def suite_environment(dev, root, ports, account):
|
||||
"""The environment the smoke suite reads its target instance from.
|
||||
|
||||
Deliberately the same values `odysseus dev env` prints, plus the dev
|
||||
admin account, so a manual `pytest tests/smoke` under
|
||||
`eval $(odysseus dev env)` behaves the way this tool does.
|
||||
"""
|
||||
data_dir = dev.dev_dir(root) / "data"
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"APP_PORT": str(ports["app"]),
|
||||
"CHROMADB_PORT": str(ports["chroma"]),
|
||||
"ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
|
||||
"ODYSSEUS_DATA_DIR": str(data_dir),
|
||||
"DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
|
||||
"ODYSSEUS_ADMIN_USER": account["username"],
|
||||
"ODYSSEUS_ADMIN_PASSWORD": account["password"],
|
||||
})
|
||||
return env
|
||||
|
||||
|
||||
def boot(dev, root, args):
|
||||
"""Start the instance, or adopt one already running in this worktree.
|
||||
|
||||
Returns (started_by_us, note). A reused instance is never restarted
|
||||
without being asked: it may be someone's debugging session, and the
|
||||
one thing it can cost us is the email fixture flag, which the suite
|
||||
reports as a skip rather than a pass.
|
||||
"""
|
||||
already = dev.running_app(dev.read_state(root))
|
||||
if already and args.restart:
|
||||
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
|
||||
cwd=str(root), check=False)
|
||||
already = None
|
||||
if already:
|
||||
return False, (
|
||||
f"reusing the instance already up on port {already['port']} "
|
||||
f"(pid {already['pid']}). If it was not booted with "
|
||||
f"{EMAIL_FIXTURE_ENV}=1 the Email area will report a skip; "
|
||||
f"re-run with --restart for a clean boot."
|
||||
)
|
||||
if args.no_boot:
|
||||
fail(
|
||||
"nothing is running in this worktree and --no-boot was passed.\n"
|
||||
" boot it with `odysseus dev up`, or drop --no-boot.",
|
||||
)
|
||||
|
||||
command = [sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "up"]
|
||||
if args.venv:
|
||||
command += ["--venv", args.venv]
|
||||
env = dict(os.environ)
|
||||
env[EMAIL_FIXTURE_ENV] = "1"
|
||||
result = subprocess.run(command, cwd=str(root), env=env, check=False)
|
||||
if result.returncode != 0:
|
||||
fail(f"`odysseus dev up` exited {result.returncode}; not running the suite.")
|
||||
return True, ""
|
||||
|
||||
|
||||
def venv_python(dev, root, args):
|
||||
"""The interpreter to run pytest with: the one the app runs under."""
|
||||
recorded = (dev.read_state(root) or {}).get("venv")
|
||||
for candidate in (Path(args.venv).expanduser() if args.venv else None,
|
||||
Path(recorded) if recorded else None,
|
||||
Path(root) / "venv"):
|
||||
if candidate and (candidate / "bin" / "python").exists():
|
||||
return candidate / "bin" / "python"
|
||||
fail(
|
||||
f"no interpreter found for the suite (looked at {Path(root) / 'venv'}).\n"
|
||||
f" build one with ./start-macos.sh, or pass --venv."
|
||||
)
|
||||
|
||||
|
||||
def cmd_run(args):
|
||||
dev = load_dev()
|
||||
root = dev.find_repo_root(Path.cwd())
|
||||
if root is None:
|
||||
fail(f"not inside an Odysseus checkout (looked upwards from {Path.cwd()})", code=2)
|
||||
|
||||
if args.areas:
|
||||
sys.path.insert(0, str(root))
|
||||
from tests.smoke import areas
|
||||
sys.stdout.write(areas.render_table({}, header="Odysseus release smoke - coverage") + "\n")
|
||||
return 0
|
||||
|
||||
ports = dev.derive_ports(root)
|
||||
account = dev.credentials(root)
|
||||
started_by_us, note = boot(dev, root, args)
|
||||
if note:
|
||||
sys.stdout.write(f" {note}\n")
|
||||
|
||||
python = venv_python(dev, root, args)
|
||||
env = suite_environment(dev, root, ports, account)
|
||||
command = [str(python), "-m", "pytest", SMOKE_SUITE, "-q"] + list(args.pytest_args)
|
||||
sys.stdout.write(f"\n running {SMOKE_SUITE} against http://127.0.0.1:{ports['app']}\n\n")
|
||||
# Flush before handing the terminal to pytest, or our own lines land
|
||||
# after its output and the report reads out of order.
|
||||
sys.stdout.flush()
|
||||
result = subprocess.run(command, cwd=str(root), env=env, check=False)
|
||||
|
||||
if started_by_us and not args.keep_up:
|
||||
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
|
||||
cwd=str(root), check=False)
|
||||
elif started_by_us:
|
||||
sys.stdout.write(
|
||||
f"\n left running: http://127.0.0.1:{ports['app']} "
|
||||
f"({account['username']} / {account['password']})\n"
|
||||
f" stop it with `odysseus dev down`\n"
|
||||
)
|
||||
# `cli.run` discards a returned value but lets SystemExit through, and
|
||||
# a smoke run's exit code is the whole point of having one command.
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(result.returncode)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = common_parser("odysseus-smoke",
|
||||
"Boot this worktree and run the release smoke suite.")
|
||||
parser.add_argument("--keep-up", action="store_true",
|
||||
help="leave the instance running after the suite finishes")
|
||||
parser.add_argument("--no-boot", action="store_true",
|
||||
help="require an instance already up in this worktree")
|
||||
parser.add_argument("--restart", action="store_true",
|
||||
help="stop a running instance and boot a fresh one")
|
||||
parser.add_argument("--venv", help="use this venv instead of ./venv")
|
||||
parser.add_argument("--areas", action="store_true",
|
||||
help="print the coverage table and exit without booting")
|
||||
parser.add_argument("pytest_args", nargs="*", metavar="-- PYTEST ARGS",
|
||||
help="arguments forwarded to pytest after a literal --")
|
||||
parser.set_defaults(func=cmd_run)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser()))
|
||||
Executable
+586
@@ -0,0 +1,586 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only audit of what one git ref carries that another does not.
|
||||
|
||||
Two long-lived lines that are not merged into each other drift silently. A fix
|
||||
landed on one of them leaves no mark on the other, and nothing in git tells you
|
||||
so: the two histories share only a distant merge base, so `git log A..B` lists
|
||||
thousands of commits whose content is in fact already present on both sides
|
||||
under different SHAs.
|
||||
|
||||
This script answers the question that actually matters at release time -- which
|
||||
commits on the source ref left *no trace at all* in the target ref -- by
|
||||
sampling distinctive added lines from each commit and searching the target tree
|
||||
for them. It also reports the file-level presence diff, which catches the case
|
||||
the line sampling cannot: a fix whose production change was reproduced on the
|
||||
target but whose test file was never brought over.
|
||||
|
||||
It is read-only. It runs `git log`, `git show`, `git diff`, `git grep`,
|
||||
`git ls-tree` and `git merge-base`, writes nothing to the repository, touches no
|
||||
remote, and does not import the Odysseus application package.
|
||||
|
||||
Usage:
|
||||
|
||||
scripts/ref_parity_audit.py --source public/dev --target lab --since 2026-08-10
|
||||
|
||||
Read `docs/ref-parity-audit.md` before acting on the output: the line sampling
|
||||
is a heuristic and the report labels which of its verdicts are exact.
|
||||
"""
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Paths whose contents are never worth probing: vendored third-party code,
|
||||
# committed build output, lockfiles and binaries. A distinctive line does not
|
||||
# exist in a minified bundle, and a lockfile churns on every dependency bump.
|
||||
DEFAULT_EXCLUDES = (
|
||||
"static/lib/*",
|
||||
"static/js/editor/build/*",
|
||||
"*.min.js",
|
||||
"*.min.css",
|
||||
"*.map",
|
||||
"package-lock.json",
|
||||
"*.lock",
|
||||
"*.png",
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.gif",
|
||||
"*.ico",
|
||||
"*.webp",
|
||||
"*.svg",
|
||||
"*.pdf",
|
||||
"*.woff",
|
||||
"*.woff2",
|
||||
"*.ttf",
|
||||
"*.otf",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.zip",
|
||||
"*.gz",
|
||||
)
|
||||
|
||||
# A probe has to be long enough and carry enough named things to be unlikely to
|
||||
# appear by coincidence. `return hosts` is in a hundred files; a line naming two
|
||||
# identifiers over 24 characters is usually unique to the change that added it.
|
||||
MIN_PROBE_LENGTH = 24
|
||||
MIN_PROBE_IDENTIFIERS = 2
|
||||
IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
|
||||
|
||||
FIELD_SEP = "\x1f"
|
||||
|
||||
VERDICT_ABSENT = "absent"
|
||||
VERDICT_PARTIAL = "partial"
|
||||
VERDICT_PRESENT = "present"
|
||||
VERDICT_NO_PROBE = "no-probe"
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
"""A git invocation failed in a way the audit cannot work around."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
sha: str
|
||||
author: str
|
||||
date: str
|
||||
subject: str
|
||||
parent_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommitVerdict:
|
||||
commit: Commit
|
||||
probes: tuple[str, ...]
|
||||
found: tuple[str, ...]
|
||||
paths: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def verdict(self) -> str:
|
||||
if not self.probes:
|
||||
return VERDICT_NO_PROBE
|
||||
if not self.found:
|
||||
return VERDICT_ABSENT
|
||||
if len(self.found) < len(self.probes):
|
||||
return VERDICT_PARTIAL
|
||||
return VERDICT_PRESENT
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
source: str
|
||||
source_sha: str
|
||||
target: str
|
||||
target_sha: str
|
||||
merge_base: str
|
||||
since: str | None
|
||||
until: str | None
|
||||
traversal: str
|
||||
probe_limit: int
|
||||
verdicts: list[CommitVerdict] = field(default_factory=list)
|
||||
source_only_files: tuple[str, ...] = ()
|
||||
target_only_files: tuple[str, ...] = ()
|
||||
|
||||
def by_verdict(self, verdict: str) -> list[CommitVerdict]:
|
||||
return [v for v in self.verdicts if v.verdict == verdict]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# git plumbing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def run_git(args: Sequence[str], repo: Path) -> str:
|
||||
"""Run a read-only git command and return stdout, raising on failure."""
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def resolve_ref(ref: str, repo: Path) -> str:
|
||||
return run_git(["rev-parse", "--short=8", ref], repo).strip()
|
||||
|
||||
|
||||
def merge_base(source: str, target: str, repo: Path) -> str:
|
||||
try:
|
||||
return run_git(["merge-base", source, target], repo).strip()[:8]
|
||||
except GitError:
|
||||
# Unrelated histories have no merge base. That is a finding, not a crash.
|
||||
return ""
|
||||
|
||||
|
||||
def list_commits(
|
||||
source: str,
|
||||
target: str,
|
||||
repo: Path,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
traversal: str = "linear",
|
||||
) -> list[Commit]:
|
||||
"""List commits reachable from `source` but not from `target`.
|
||||
|
||||
`linear` drops merge commits and reports the individual authored commits,
|
||||
which is what finds a fix that arrived on a side branch. `first-parent`
|
||||
reports one entry per merge into the source branch, which reads as one row
|
||||
per merged pull request.
|
||||
"""
|
||||
args = [
|
||||
"log",
|
||||
"--date=short",
|
||||
f"--format=%H{FIELD_SEP}%an{FIELD_SEP}%cd{FIELD_SEP}%p{FIELD_SEP}%s",
|
||||
]
|
||||
args.append("--no-merges" if traversal == "linear" else "--first-parent")
|
||||
if since:
|
||||
args.append(f"--since={since}")
|
||||
if until:
|
||||
args.append(f"--until={until}")
|
||||
args.append(f"{target}..{source}")
|
||||
|
||||
commits = []
|
||||
for line in run_git(args, repo).splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
sha, author, date, parents, subject = line.split(FIELD_SEP, 4)
|
||||
commits.append(
|
||||
Commit(
|
||||
sha=sha,
|
||||
author=author,
|
||||
date=date,
|
||||
subject=subject,
|
||||
parent_count=len(parents.split()) if parents.strip() else 0,
|
||||
)
|
||||
)
|
||||
return commits
|
||||
|
||||
|
||||
def commit_diff(commit: Commit, repo: Path) -> str:
|
||||
"""Return the commit's patch with no context lines.
|
||||
|
||||
A merge is diffed against its first parent so the whole merged content is
|
||||
visible; `git show` would otherwise print only the conflicting hunks.
|
||||
"""
|
||||
if commit.parent_count > 1:
|
||||
return run_git(
|
||||
["diff", "--no-color", "--no-renames", "-U0", f"{commit.sha}^1", commit.sha],
|
||||
repo,
|
||||
)
|
||||
return run_git(
|
||||
["show", "--no-color", "--no-renames", "-U0", "--format=", commit.sha], repo
|
||||
)
|
||||
|
||||
|
||||
def probe_present(probe: str, ref: str, repo: Path) -> bool:
|
||||
"""Is this exact text anywhere in the ref's tree?
|
||||
|
||||
The whole tree is searched on purpose. The question is whether the change
|
||||
left a trace at all, not whether it landed in the same file -- a ported fix
|
||||
routinely moves, and the exclusion list only governs where probes come
|
||||
from.
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), "grep", "--fixed-strings", "--quiet", "-e", probe, ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode not in (0, 1):
|
||||
raise GitError(f"git grep failed for {ref}: {proc.stderr.strip()}")
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def list_tree(ref: str, repo: Path) -> list[str]:
|
||||
raw = run_git(["ls-tree", "-r", "-z", "--name-only", ref], repo)
|
||||
return [path for path in raw.split("\0") if path]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# probe selection (pure)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def is_excluded(path: str, patterns: Iterable[str]) -> bool:
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
return any(
|
||||
fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(name, pattern)
|
||||
for pattern in patterns
|
||||
)
|
||||
|
||||
|
||||
def added_lines(patch: str, excludes: Iterable[str]) -> list[tuple[str, str]]:
|
||||
"""Extract `(path, added line)` pairs from a unified diff."""
|
||||
results = []
|
||||
path = None
|
||||
skip = False
|
||||
for line in patch.splitlines():
|
||||
if line.startswith("+++ "):
|
||||
target = line[4:].strip()
|
||||
path = None if target == "/dev/null" else target[2:] if target.startswith("b/") else target
|
||||
skip = path is None or is_excluded(path, excludes)
|
||||
elif line.startswith("--- ") or line.startswith("diff --git "):
|
||||
continue
|
||||
elif line.startswith("+") and path and not skip:
|
||||
results.append((path, line[1:]))
|
||||
return results
|
||||
|
||||
|
||||
def probe_score(text: str) -> int:
|
||||
"""Rank a candidate probe: distinct named things first, then length."""
|
||||
identifiers = set(IDENTIFIER_RE.findall(text))
|
||||
return len(identifiers) * 1000 + min(len(text), 400)
|
||||
|
||||
|
||||
def is_probe_candidate(text: str) -> bool:
|
||||
stripped = text.strip()
|
||||
if len(stripped) < MIN_PROBE_LENGTH:
|
||||
return False
|
||||
if "\0" in stripped:
|
||||
return False
|
||||
return len(set(IDENTIFIER_RE.findall(stripped))) >= MIN_PROBE_IDENTIFIERS
|
||||
|
||||
|
||||
def pick_probes(lines: Sequence[tuple[str, str]], limit: int) -> list[str]:
|
||||
"""Pick up to `limit` distinctive stripped lines, highest-scoring first.
|
||||
|
||||
Leading and trailing whitespace is dropped so a re-indented port still
|
||||
counts as present. Ties break on first appearance, keeping the output
|
||||
stable across runs.
|
||||
"""
|
||||
seen: dict[str, int] = {}
|
||||
for index, (_path, text) in enumerate(lines):
|
||||
stripped = text.strip()
|
||||
if not is_probe_candidate(stripped) or stripped in seen:
|
||||
continue
|
||||
seen[stripped] = index
|
||||
ranked = sorted(seen, key=lambda text: (-probe_score(text), seen[text]))
|
||||
return ranked[:limit]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# audit
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def audit(
|
||||
source: str,
|
||||
target: str,
|
||||
repo: Path,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
traversal: str = "linear",
|
||||
probe_limit: int = 4,
|
||||
excludes: Sequence[str] = DEFAULT_EXCLUDES,
|
||||
progress: bool = False,
|
||||
) -> Report:
|
||||
report = Report(
|
||||
source=source,
|
||||
source_sha=resolve_ref(source, repo),
|
||||
target=target,
|
||||
target_sha=resolve_ref(target, repo),
|
||||
merge_base=merge_base(source, target, repo),
|
||||
since=since,
|
||||
until=until,
|
||||
traversal=traversal,
|
||||
probe_limit=probe_limit,
|
||||
)
|
||||
|
||||
commits = list_commits(source, target, repo, since, until, traversal)
|
||||
for index, commit in enumerate(commits, start=1):
|
||||
if progress:
|
||||
print(
|
||||
f"\r[{index}/{len(commits)}] {commit.sha[:8]}",
|
||||
end="",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
lines = added_lines(commit_diff(commit, repo), excludes)
|
||||
probes = pick_probes(lines, probe_limit)
|
||||
found = tuple(p for p in probes if probe_present(p, target, repo))
|
||||
report.verdicts.append(
|
||||
CommitVerdict(
|
||||
commit=commit,
|
||||
probes=tuple(probes),
|
||||
found=found,
|
||||
paths=tuple(dict.fromkeys(path for path, _ in lines)),
|
||||
)
|
||||
)
|
||||
if progress:
|
||||
print("", file=sys.stderr)
|
||||
|
||||
source_files = {p for p in list_tree(source, repo) if not is_excluded(p, excludes)}
|
||||
target_files = {p for p in list_tree(target, repo) if not is_excluded(p, excludes)}
|
||||
report.source_only_files = tuple(sorted(source_files - target_files))
|
||||
report.target_only_files = tuple(sorted(target_files - source_files))
|
||||
return report
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# rendering
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _commit_table(verdicts: Sequence[CommitVerdict]) -> list[str]:
|
||||
rows = [
|
||||
"| Commit | Committed | Author | Probes found | Subject |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for item in verdicts:
|
||||
rows.append(
|
||||
f"| `{item.commit.sha[:8]}` | {item.commit.date} | {item.commit.author} "
|
||||
f"| {len(item.found)}/{len(item.probes)} | {item.commit.subject} |"
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _file_list(paths: Sequence[str], top: int) -> list[str]:
|
||||
lines = [f"- `{path}`" for path in paths[:top]]
|
||||
if len(paths) > top:
|
||||
lines.append(f"- … and {len(paths) - top} more")
|
||||
return lines
|
||||
|
||||
|
||||
def render_markdown(report: Report, top: int = 50) -> str:
|
||||
absent = report.by_verdict(VERDICT_ABSENT)
|
||||
partial = report.by_verdict(VERDICT_PARTIAL)
|
||||
present = report.by_verdict(VERDICT_PRESENT)
|
||||
no_probe = report.by_verdict(VERDICT_NO_PROBE)
|
||||
|
||||
window = []
|
||||
if report.since:
|
||||
window.append(f"since {report.since}")
|
||||
if report.until:
|
||||
window.append(f"until {report.until}")
|
||||
|
||||
out = [
|
||||
"# Ref parity audit",
|
||||
"",
|
||||
f"Source `{report.source}` @ `{report.source_sha}` → "
|
||||
f"target `{report.target}` @ `{report.target_sha}`.",
|
||||
f"Merge base `{report.merge_base or 'none (unrelated histories)'}`.",
|
||||
f"{len(report.verdicts)} commits on the source and not the target "
|
||||
f"({report.traversal} traversal"
|
||||
+ (", " + ", ".join(window) if window else "")
|
||||
+ f"), up to {report.probe_limit} probes each.",
|
||||
"",
|
||||
f"No trace in the target: **{len(absent)}**. "
|
||||
f"Partly present: **{len(partial)}**. "
|
||||
f"Fully present: **{len(present)}**. "
|
||||
f"Unprobeable: **{len(no_probe)}**.",
|
||||
"",
|
||||
"## Commits with no trace in the target",
|
||||
"",
|
||||
]
|
||||
out += _commit_table(absent) if absent else ["None."]
|
||||
|
||||
out += [
|
||||
"",
|
||||
"## Commits only partly present",
|
||||
"",
|
||||
"A partial verdict is inconclusive, not a finding: a line can move or be "
|
||||
"rewritten by a refactor on the target and still be the same change. Read the "
|
||||
"diff before porting anything from this table.",
|
||||
"",
|
||||
]
|
||||
out += _commit_table(partial) if partial else ["None."]
|
||||
|
||||
out += ["", "## Commits with no usable probe", ""]
|
||||
if no_probe:
|
||||
out += [
|
||||
"Deletion-only commits, and commits touching nothing but excluded paths. "
|
||||
"The audit has no verdict on these.",
|
||||
"",
|
||||
] + _commit_table(no_probe)
|
||||
else:
|
||||
out.append("None.")
|
||||
|
||||
out += [
|
||||
"",
|
||||
f"## Files on the source and not the target ({len(report.source_only_files)})",
|
||||
"",
|
||||
"Exact, not sampled. A file here whose commit is reported fully present is "
|
||||
"usually a fix that was reproduced without its test.",
|
||||
"",
|
||||
]
|
||||
out += _file_list(report.source_only_files, top) if report.source_only_files else ["None."]
|
||||
|
||||
out += [
|
||||
"",
|
||||
f"## Files on the target and not the source ({len(report.target_only_files)})",
|
||||
"",
|
||||
]
|
||||
out += _file_list(report.target_only_files, top) if report.target_only_files else ["None."]
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_json(report: Report) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"source": {"ref": report.source, "sha": report.source_sha},
|
||||
"target": {"ref": report.target, "sha": report.target_sha},
|
||||
"merge_base": report.merge_base,
|
||||
"since": report.since,
|
||||
"until": report.until,
|
||||
"traversal": report.traversal,
|
||||
"probe_limit": report.probe_limit,
|
||||
"totals": {
|
||||
verdict: len(report.by_verdict(verdict))
|
||||
for verdict in (
|
||||
VERDICT_ABSENT,
|
||||
VERDICT_PARTIAL,
|
||||
VERDICT_PRESENT,
|
||||
VERDICT_NO_PROBE,
|
||||
)
|
||||
},
|
||||
"commits": [
|
||||
{
|
||||
"sha": item.commit.sha,
|
||||
"date": item.commit.date,
|
||||
"author": item.commit.author,
|
||||
"subject": item.commit.subject,
|
||||
"verdict": item.verdict,
|
||||
"probes": list(item.probes),
|
||||
"probes_found": list(item.found),
|
||||
"paths": list(item.paths),
|
||||
}
|
||||
for item in report.verdicts
|
||||
],
|
||||
"source_only_files": list(report.source_only_files),
|
||||
"target_only_files": list(report.target_only_files),
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# cli
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def positive_int(value: str) -> int:
|
||||
parsed = int(value)
|
||||
if parsed < 1:
|
||||
raise argparse.ArgumentTypeError("must be 1 or greater")
|
||||
return parsed
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read-only audit of which commits on one ref left no trace in another."
|
||||
)
|
||||
parser.add_argument("--source", required=True, help="Ref whose commits are audited")
|
||||
parser.add_argument("--target", required=True, help="Ref searched for traces of them")
|
||||
parser.add_argument("--repo", default=str(REPO_ROOT), help="Repository to run in")
|
||||
parser.add_argument("--since", help="Only commits committed on or after this date")
|
||||
parser.add_argument("--until", help="Only commits committed on or before this date")
|
||||
parser.add_argument(
|
||||
"--traversal",
|
||||
choices=["linear", "first-parent"],
|
||||
default="linear",
|
||||
help="linear: individual commits, no merges. first-parent: one row per merge",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--probes", type=positive_int, default=4, help="Probe lines sampled per commit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="GLOB",
|
||||
help="Extra path glob whose lines are not used as probes (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-default-excludes",
|
||||
action="store_true",
|
||||
help="Drop the built-in vendored/lockfile/binary exclusions",
|
||||
)
|
||||
parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
|
||||
parser.add_argument("--top", type=positive_int, default=50, help="Rows per file list")
|
||||
parser.add_argument("--output", help="Write the report here instead of stdout")
|
||||
parser.add_argument("--quiet", action="store_true", help="No progress output")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
excludes = list(args.exclude)
|
||||
if not args.no_default_excludes:
|
||||
excludes = list(DEFAULT_EXCLUDES) + excludes
|
||||
|
||||
try:
|
||||
report = audit(
|
||||
source=args.source,
|
||||
target=args.target,
|
||||
repo=Path(args.repo),
|
||||
since=args.since,
|
||||
until=args.until,
|
||||
traversal=args.traversal,
|
||||
probe_limit=args.probes,
|
||||
excludes=excludes,
|
||||
progress=not args.quiet and sys.stderr.isatty(),
|
||||
)
|
||||
except GitError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
text = render_json(report) if args.format == "json" else render_markdown(report, args.top)
|
||||
if args.output:
|
||||
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,10 +1,26 @@
|
||||
/** Card layout and reconciliation against the served assets; no user mutations. */
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const ORIGIN = 'http://127.0.0.1:7011';
|
||||
|
||||
/** The `<link rel="stylesheet">` tags the app shell ships, in shell order. */
|
||||
async function shellStylesheets() {
|
||||
const response = await fetch(`${ORIGIN}/static/index.html`);
|
||||
if (!response.ok) throw new Error(`static/index.html returned ${response.status}`);
|
||||
const links = (await response.text()).match(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi) || [];
|
||||
if (!links.length) throw new Error('no stylesheet links found in static/index.html');
|
||||
return links.join('');
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto('http://127.0.0.1:7011/static/test-fixtures/browser-catalog.html');
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css"><main style="padding:16px"><div id="chat-history"><p>Existing conversation</p></div></main>');
|
||||
await page.goto(`${ORIGIN}/static/test-fixtures/browser-catalog.html`);
|
||||
// Read the shell's stylesheets out of index.html rather than naming one
|
||||
// here. style.css is now a set of ordered fragments, and a hardcoded link
|
||||
// to a file that has moved does not fail - it renders unstyled and the
|
||||
// layout checks below pass against nothing.
|
||||
await page.setContent(`${await shellStylesheets()}<main style="padding:16px"><div id="chat-history"><p>Existing conversation</p></div></main>`);
|
||||
const checks = await page.evaluate(async () => {
|
||||
const { renderResearchCards } = await import('/static/js/backgroundToolJobs.js');
|
||||
const box = document.querySelector('#chat-history');
|
||||
|
||||
@@ -8,7 +8,8 @@ This spec covers mail and contacts in:
|
||||
|
||||
- app wiring in `app.py`;
|
||||
- `core.database.EmailAccount`;
|
||||
- `routes/email_routes.py`, `routes/email_helpers.py`, and `routes/email_pollers.py`;
|
||||
- canonical `routes/email/email_routes.py`, `routes/email/email_helpers.py`, and
|
||||
`routes/email/email_pollers.py`, plus their shims at the old flat paths;
|
||||
- email threading in `src/email_thread_parser.py`;
|
||||
- email MCP tools in `mcp_servers/email_server.py`;
|
||||
- canonical contact/CardDAV routes in `routes/contacts/contacts_routes.py`,
|
||||
|
||||
+3
-1
@@ -97,6 +97,8 @@ Current major frontend areas include:
|
||||
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
|
||||
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
|
||||
|
||||
`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade.
|
||||
|
||||
Coordinator ownership:
|
||||
|
||||
- `static/app.js` owns late orchestration, global fetch 401 redirects, sidebar/tool route wiring, and many `window.*` compatibility bridges;
|
||||
@@ -153,7 +155,7 @@ Missing coverage includes:
|
||||
## Current Gaps
|
||||
|
||||
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
|
||||
- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets only.
|
||||
- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only.
|
||||
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
|
||||
- `window.*` compatibility bridges remain widespread.
|
||||
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
|
||||
|
||||
@@ -16,7 +16,7 @@ This spec covers durable state in:
|
||||
- `src/attachment_refs.py`, `src/upload_handler.py`, and
|
||||
`routes/upload_routes.py` for durable upload references and retention;
|
||||
- JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers;
|
||||
- `routes/email_helpers.py` scheduled-email storage;
|
||||
- canonical `routes/email/email_helpers.py` scheduled-email storage;
|
||||
- `routes/backup_routes.py` and `scripts/odysseus-backup`;
|
||||
- runtime data under `data/`.
|
||||
|
||||
@@ -61,7 +61,7 @@ Email default-account state is serialized per owner. Startup normalizes legacy d
|
||||
|
||||
`core/models.py` owns pure dataclasses used by `SessionManager`. It does not own database persistence.
|
||||
|
||||
`routes/email_helpers.py` owns a second SQLite database at `data/scheduled_emails.db` for scheduled email, summary, reply, tag, sender-signature, urgency-alert, calendar-extraction, and cache state. Its migrations and owner backfills are local to that module, not `core/database.py`, and those auxiliary tables are owner-scoped.
|
||||
`routes/email/email_helpers.py` owns a second SQLite database at `data/scheduled_emails.db` for scheduled email, summary, reply, tag, sender-signature, urgency-alert, calendar-extraction, and cache state. Its migrations and owner backfills are local to that module, not `core/database.py`, and those auxiliary tables are owner-scoped.
|
||||
|
||||
## Migration Policy
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ This spec covers settings and admin surfaces in:
|
||||
- `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references;
|
||||
- `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`;
|
||||
- `src/agent_loop.py` for stale agent prompt references to settings APIs;
|
||||
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
|
||||
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
|
||||
- CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`.
|
||||
|
||||
Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs.
|
||||
@@ -75,7 +75,7 @@ Admin gates inherit the auth contracts in `auth-security.md`: normal deployments
|
||||
- bundled accessibility font selection such as OpenDyslexic and text-size variable application;
|
||||
- CSS variable application.
|
||||
|
||||
`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
|
||||
`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
|
||||
|
||||
Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes.
|
||||
|
||||
@@ -184,7 +184,7 @@ Current targeted coverage includes settings store fallback/error paths, settings
|
||||
- Add diagnostics tests for broader error redaction and sensitive output limits.
|
||||
- Add admin wipe tests for every wipe kind, unknown-kind 400, rollback behavior, and admin gating.
|
||||
- Add vault route tests for session omission, permission setting, login/unlock failures, lock/logout clearing, corrupt config, and admin gates.
|
||||
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests.
|
||||
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior.
|
||||
- Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names.
|
||||
- Decide whether backup/import should preserve explicit owner fields or force imported owner ownership.
|
||||
- Continue moving shell/navigation concerns out of the still-large `static/js/settings.js` and `static/js/admin.js` domain boundary without duplicating registry ownership.
|
||||
- Continue decomposing the still-large `static/js/settings.js` (5,583 lines) and `static/js/admin.js` (4,122 lines) without duplicating registry ownership. The shell and navigation concerns now live in `static/js/settings/`; what remains in `settings.js` is panel code, and `initUnifiedIntegrations()` alone is ~2,140 lines of it. `admin.js` has no extracted shell of its own yet.
|
||||
|
||||
+15
-4
@@ -97,19 +97,30 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
|
||||
pending = fut
|
||||
owner = True
|
||||
if not owner:
|
||||
return await pending
|
||||
# A cancelled waiter must not cancel the shared Future for the owner
|
||||
# and every other waiter.
|
||||
return await asyncio.shield(pending)
|
||||
try:
|
||||
val = await fetch()
|
||||
async with _shared_cache_lock:
|
||||
_shared_cache[key] = (time.monotonic() + ttl, val)
|
||||
_shared_cache_pending.pop(key, None)
|
||||
pending.set_result(val)
|
||||
return val
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is a BaseException on supported Python versions, so it
|
||||
# bypasses the Exception handler below. Wake all current waiters while
|
||||
# allowing a later caller to retry the fetch.
|
||||
pending.cancel()
|
||||
raise
|
||||
except Exception as e:
|
||||
async with _shared_cache_lock:
|
||||
_shared_cache_pending.pop(key, None)
|
||||
pending.set_exception(e)
|
||||
raise
|
||||
finally:
|
||||
# Keep this cleanup synchronous so a second cancellation cannot
|
||||
# interrupt it and leave a permanently pending Future behind. All
|
||||
# access runs on the scheduler's event-loop thread.
|
||||
if _shared_cache_pending.get(key) is pending:
|
||||
_shared_cache_pending.pop(key, None)
|
||||
|
||||
|
||||
def compute_next_run(schedule: str, scheduled_time: str,
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
|
||||
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
|
||||
import calendarModule from './js/calendar.js?v=20260914emailsource11';
|
||||
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
|
||||
import adminModule from './js/admin.js?v=20260921chatgptusage2';
|
||||
import adminModule from './js/admin.js?v=20260929csssplit';
|
||||
import settingsModule from './js/settings.js?v=20260912writingstyle3';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js';
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/* 00-tokens - part of the former static/style.css.
|
||||
*
|
||||
* The stylesheet is split by area, but the split is mechanical: rules were
|
||||
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
|
||||
* that order is what preserves the cascade. A rule sits in the file its
|
||||
* original position fell into, so an area's rules are not all in its file and
|
||||
* a file is not only that area's rules.
|
||||
*
|
||||
* Do not reorder the <link> tags in static/index.html, and do not move a rule
|
||||
* between files: with 1,151 redeclarations and 1,886 !important in this
|
||||
* cascade, either can change which declaration wins.
|
||||
*
|
||||
* static/index.html and the PRECACHE list in static/sw.js must agree on this
|
||||
* order. tests/test_static_stylesheet_manifest.py and
|
||||
* tests/test_css_computed_style_snapshot.py fail if that drifts.
|
||||
*/
|
||||
/* ============================================ */
|
||||
/* Odysseus UI — Consolidated Stylesheet */
|
||||
/* ============================================ */
|
||||
|
||||
|
||||
/* ── Variables ──
|
||||
*
|
||||
* Theme-public variables (override via theme.js or custom themes):
|
||||
* Core: --bg, --fg, --panel, --border, --red
|
||||
* Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function,
|
||||
* --hl-number, --hl-builtin, --hl-variable, --hl-params,
|
||||
* --hl-bg, --hl-fg
|
||||
* Accents: --accent-primary, --accent-error (set by theme.js)
|
||||
* Semantic: --color-error, --color-success, --color-warning, --color-danger,
|
||||
* --color-accent, --color-muted, --color-muted-alt
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--bg: #282c34;
|
||||
--fg: #9cdef2;
|
||||
--panel: #111;
|
||||
--border: #355a66;
|
||||
--red: #e06c75;
|
||||
/* Were `var(--green)` / `var(--warn)` — self-referential, so they
|
||||
resolved to invalid and every site fell back to its own literal
|
||||
(or, for sites with no fallback, painted as transparent/inherit).
|
||||
Anchor them to real hex so the token layer actually works. */
|
||||
--green: #50fa7b;
|
||||
--warn: #f0ad4e;
|
||||
|
||||
/* Syntax highlighting */
|
||||
--hl-bg: #1e2228;
|
||||
--hl-fg: #9cdef2;
|
||||
--hl-keyword: #c678dd;
|
||||
--hl-string: #e5c07b;
|
||||
--hl-comment: #828997;
|
||||
--hl-function: #61afef;
|
||||
--hl-number: #d19a66;
|
||||
--hl-builtin: #56b6c2;
|
||||
--hl-variable: #abb2bf;
|
||||
--hl-params: #a8c0d4;
|
||||
|
||||
/* Semantic colors */
|
||||
--color-error: #ff4444;
|
||||
--color-error-light: #ff6666;
|
||||
--color-success: #4caf50;
|
||||
--color-warning: #f0ad4e;
|
||||
--color-danger: #c0392b;
|
||||
--color-recording: #ff3b30;
|
||||
--color-recording-hover: #d63031;
|
||||
--color-muted: #888;
|
||||
--color-muted-alt: #6b7280;
|
||||
--color-accent: #00aaff;
|
||||
--color-agent-active: #00ff00;
|
||||
--color-brand-blue: #3b82f6;
|
||||
--color-blind-orange: #ff9800;
|
||||
--color-save-green: var(--color-success);
|
||||
--color-link-hover: #66c7ff;
|
||||
--color-subheader: #6b8a94;
|
||||
--select-bg: var(--bg);
|
||||
--select-fg: var(--fg);
|
||||
--select-option-bg: color-mix(in srgb, var(--panel) 74%, var(--bg));
|
||||
--select-option-fg: var(--fg);
|
||||
--select-option-active-bg: color-mix(in srgb, var(--accent, var(--red)) 24%, var(--panel));
|
||||
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
|
||||
themes can override without touching the goal CSS. */
|
||||
--accent-warm: #d19a66;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--bg: #f5f5f5;
|
||||
--fg: #2b2b2b;
|
||||
--panel: #fff;
|
||||
--border: #bbb;
|
||||
--hl-bg: #f9f9f9;
|
||||
--hl-fg: #2b2b2b;
|
||||
--hl-keyword: #7928a1;
|
||||
--hl-string: #986801;
|
||||
--hl-comment: #6a737d;
|
||||
--hl-function: #005cc5;
|
||||
--hl-number: #986801;
|
||||
--hl-builtin: #0070a0;
|
||||
--hl-variable: #383a42;
|
||||
--hl-params: #4a4f5c;
|
||||
--select-bg: #eaeaea;
|
||||
--select-fg: var(--fg);
|
||||
--select-option-bg: var(--panel);
|
||||
--select-option-fg: var(--fg);
|
||||
--select-option-active-bg: color-mix(in srgb, var(--red) 16%, var(--panel));
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; }
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Keep the base padding transition for older layout paths that still adjust
|
||||
the body directly. Edge docks reserve workspace room on the flex panes
|
||||
below so left + right docks can coexist without skewing the whole body. */
|
||||
transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
font-family: var(--font-family, 'Fira Code', monospace);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Self-hosted Fira Code font */
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
|
||||
/* GohuFont is the default UI face across themes; keep the shipped font local
|
||||
so the default is available before custom-font discovery completes. */
|
||||
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
|
||||
|
||||
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Code block baseline */
|
||||
pre, code, .hljs {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--red);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in srgb, var(--red) 80%, white);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: var(--red) var(--panel);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.red-text { color: var(--red); }
|
||||
|
||||
/* ── Density Overrides ── */
|
||||
|
||||
:root.density-compact { font-size: 13px; }
|
||||
:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; }
|
||||
:root.density-compact .list-item { padding: 4px 8px; }
|
||||
:root.density-compact .sidebar .section { padding: 0; }
|
||||
:root.density-spacious { font-size: 16px; }
|
||||
:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; }
|
||||
:root.density-spacious .list-item { padding: 8px 12px; }
|
||||
:root.density-spacious .sidebar .section { padding: 0; }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,839 @@
|
||||
/* 12-gallery - part of the former static/style.css.
|
||||
*
|
||||
* The stylesheet is split by area, but the split is mechanical: rules were
|
||||
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
|
||||
* that order is what preserves the cascade. A rule sits in the file its
|
||||
* original position fell into, so an area's rules are not all in its file and
|
||||
* a file is not only that area's rules.
|
||||
*
|
||||
* Do not reorder the <link> tags in static/index.html, and do not move a rule
|
||||
* between files: with 1,151 redeclarations and 1,886 !important in this
|
||||
* cascade, either can change which declaration wins.
|
||||
*
|
||||
* static/index.html and the PRECACHE list in static/sw.js must agree on this
|
||||
* order. tests/test_static_stylesheet_manifest.py and
|
||||
* tests/test_css_computed_style_snapshot.py fail if that drifts.
|
||||
*/
|
||||
.research-synapse.rs-phase-reading .rs-phase-chip,
|
||||
.research-synapse.rs-phase-analyzing .rs-phase-chip { color: var(--gold, #d4a72c); }
|
||||
.research-synapse.rs-phase-writing .rs-phase-chip { color: var(--color-success, #4caf50); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.research-synapse .rs-live-dot,
|
||||
.research-synapse .rs-pulse,
|
||||
.research-synapse .rs-node-new,
|
||||
.research-synapse .rs-node-expanded,
|
||||
.research-synapse .rs-edge-firing { animation: none !important; }
|
||||
}
|
||||
|
||||
/* ── Raw findings items (inside sources-style box) ── */
|
||||
.finding-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.finding-item .source-link {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.finding-summary {
|
||||
padding: 6px 8px 8px;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-dim, var(--fg));
|
||||
opacity: 0.85;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
margin-bottom: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.finding-item:last-child .finding-summary {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Gallery Editor
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Tab bar */
|
||||
.gallery-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.gallery-tab {
|
||||
padding: 8px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--fg);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.gallery-tab:hover { opacity: 0.85; }
|
||||
.gallery-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--red);
|
||||
}
|
||||
|
||||
/* Narrow modal tab strips should stay on one row. Resized docked windows can
|
||||
be much narrower than the viewport, so this cannot live only in mobile media
|
||||
queries. */
|
||||
.cookbook-tabs,
|
||||
.memory-tabs,
|
||||
.admin-tabs,
|
||||
.lib-tabs,
|
||||
.gallery-tabs,
|
||||
.preset-tabs {
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.cookbook-tabs::-webkit-scrollbar,
|
||||
.memory-tabs::-webkit-scrollbar,
|
||||
.admin-tabs::-webkit-scrollbar,
|
||||
.lib-tabs::-webkit-scrollbar,
|
||||
.gallery-tabs::-webkit-scrollbar,
|
||||
.preset-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.cookbook-tabs > *,
|
||||
.memory-tabs > *,
|
||||
.admin-tabs > *,
|
||||
.lib-tabs > *,
|
||||
.gallery-tabs > *,
|
||||
.preset-tabs > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cookbook-tab,
|
||||
.memory-tab,
|
||||
.admin-tab,
|
||||
.lib-tab,
|
||||
.gallery-tab,
|
||||
.preset-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.gallery-tab {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@container (max-width: 360px) {
|
||||
.cookbook-tab:has(svg),
|
||||
.memory-tab:has(svg),
|
||||
.admin-tab:has(svg),
|
||||
.lib-tab:has(svg),
|
||||
.gallery-tab:has(svg),
|
||||
.preset-tab:has(svg) {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.cookbook-tab:has(svg) svg,
|
||||
.memory-tab:has(svg) svg,
|
||||
.admin-tab:has(svg) svg,
|
||||
.lib-tab:has(svg) svg,
|
||||
.gallery-tab:has(svg) svg,
|
||||
.preset-tab:has(svg) svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 0 !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.memory-tab:has(svg) .memory-count,
|
||||
.gallery-tab:has(svg) .gallery-tab-label,
|
||||
.gallery-tab:has(svg) .gallery-tab-close,
|
||||
.cookbook-tab:has(svg) .cookbook-tab-count,
|
||||
.preset-tab:has(svg) .preset-count {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
/* Icon + label layout inside each tab. */
|
||||
.gallery-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-tab-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.gallery-tab.active .gallery-tab-icon { opacity: 1; }
|
||||
/* Close × on the Edit tab — appears on hover. */
|
||||
.gallery-tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
/* Use a slightly larger glyph at a larger line-height so the × sits
|
||||
visually centered — the U+00D7 character has uneven em-box metrics. */
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding-bottom: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gallery-tab:hover .gallery-tab-close,
|
||||
.gallery-tab.active .gallery-tab-close { opacity: 0.65; }
|
||||
/* Hide the close × entirely on the Edit tab when there's no edit open. */
|
||||
.gallery-tab[data-tab="editor"]:not(.has-edit) .gallery-tab-close { display: none !important; }
|
||||
.gallery-tab-close:hover { opacity: 1 !important; background: color-mix(in srgb, var(--red) 25%, transparent); color: var(--red); }
|
||||
/* Inline rename input shown when the Edit tab is double-clicked. */
|
||||
.gallery-tab-rename-input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 3px;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 1px 4px;
|
||||
width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Albums tab — grid of album cards with cover thumbnails. */
|
||||
.gallery-albums-container {
|
||||
padding: 12px 4px;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto;
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.gallery-settings-container {
|
||||
padding: 8px 4px;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-albums-container.gallery-dragover {
|
||||
border-color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 5%, transparent);
|
||||
}
|
||||
.gallery-albums-empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
padding: 48px 16px;
|
||||
opacity: 0.7;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gallery-albums-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery-album-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.gallery-album-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.gallery-album-menu-btn {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 24px; height: 24px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
||||
color: var(--fg);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(4px);
|
||||
/* Plain "…" text — the previous SVG was rendering as a flat black
|
||||
block on some themes when its currentColor didn't contrast. */
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
.gallery-album-card:hover .gallery-album-menu-btn,
|
||||
.gallery-album-menu-btn:focus-visible { opacity: 1; }
|
||||
.gallery-album-menu-btn:hover { background: var(--bg); }
|
||||
.gallery-album-menu-pop {
|
||||
position: absolute;
|
||||
top: 34px; right: 6px;
|
||||
min-width: 140px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
.gallery-album-menu-pop[hidden] { display: none; }
|
||||
.gallery-album-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: color-mix(in srgb, var(--fg) 5%, var(--bg));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-album-placeholder {
|
||||
opacity: 0.4;
|
||||
font-size: 40px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.gallery-album-info {
|
||||
padding: 8px 10px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.gallery-album-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-album-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.gallery-album-card-add {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.gallery-album-card-add:hover { opacity: 1; }
|
||||
.gallery-album-action-tile {
|
||||
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
|
||||
}
|
||||
|
||||
/* Edit-tab empty state — shown before any image is loaded. */
|
||||
.gallery-editor-landing {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 28px 16px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
.gallery-editor-landing h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gallery-editor-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.gallery-editor-heading > svg { opacity: 0.6; flex: 0 0 auto; }
|
||||
/* "vision model" link in the AI-tagging description → opens Settings. */
|
||||
.ge-vision-link {
|
||||
color: var(--accent, var(--red));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ge-vision-link:hover { opacity: 0.8; }
|
||||
.ge-alpha-tag {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent);
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
.gallery-editor-landing-actions {
|
||||
display: flex; gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* Bigger primary action buttons specifically inside the editor landing —
|
||||
the gallery-wide .gallery-select-btn is a compact toolbar style which is
|
||||
too small for the empty-state hero. */
|
||||
.gallery-editor-landing-actions .gallery-select-btn {
|
||||
padding: 7px 22px 17px;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Visible template picker: every canvas ratio is inspectable without opening
|
||||
a native menu, and its SVG outline previews the resulting canvas shape. */
|
||||
.gallery-editor-template-picker {
|
||||
width: min(760px, 96%);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.gallery-editor-template-picker h4 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.gallery-editor-template-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(105px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-editor-template-option {
|
||||
height: 72px;
|
||||
min-width: 0;
|
||||
padding: 6px 7px;
|
||||
display: grid;
|
||||
grid-template-rows: 28px auto auto;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-editor-template-option svg { color: var(--accent, var(--red)); }
|
||||
.gallery-editor-template-option:hover,
|
||||
.gallery-editor-template-option:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 7%, var(--panel));
|
||||
}
|
||||
.gallery-editor-template-option span {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gallery-editor-template-option small {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
opacity: 0.52;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.gallery-editor-landing {
|
||||
justify-content: flex-start;
|
||||
gap: 7px;
|
||||
padding: 18px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-editor-landing-actions {
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.gallery-editor-landing-actions .gallery-select-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
padding: 6px 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gallery-editor-template-picker {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.gallery-editor-template-options {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
/* The common square, widescreen, portrait, and Instagram presets are
|
||||
enough to start on a phone. Keep the print and 4K presets on desktop. */
|
||||
.gallery-editor-template-option:nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Saved-drafts grid on the editor landing. Surfaces every persisted
|
||||
in-progress project so the user can resume without re-opening the
|
||||
original photo. Each card: thumbnail + name + last-saved hint + ×. */
|
||||
.gallery-editor-drafts {
|
||||
width: min(720px, 92%);
|
||||
margin: 28px auto 0; /* centered */
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.gallery-editor-drafts {
|
||||
position: relative;
|
||||
}
|
||||
.gallery-editor-drafts-loading {
|
||||
position: absolute;
|
||||
inset: 30px 0 0 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.gallery-editor-drafts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center; /* center the title / search / select */
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gallery-editor-drafts-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gallery-editor-drafts-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 26px; /* same thickness as the Select button */
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.gallery-editor-drafts-search:focus { outline: none; border-color: var(--red); }
|
||||
/* The shared .gallery-select-btn has a -4px top margin + tall asymmetric padding
|
||||
meant for the main gallery toolbar; in this header it makes the button a
|
||||
different height / offset from the search box. Normalize so it lines up. */
|
||||
.gallery-editor-drafts-header .gallery-select-btn {
|
||||
margin-top: 0 !important;
|
||||
height: 26px; /* match the search bar */
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-official-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--fg);
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.cookbook-official-filter > span:first-child {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 9px;
|
||||
}
|
||||
.cookbook-official-filter .toggle {
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
}
|
||||
.cookbook-official-filter .toggle .slider:before {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
.cookbook-official-filter .toggle input:checked + .slider:before {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
#gallery-editor-drafts-select { margin-left: auto; flex-shrink: 0; }
|
||||
#gallery-editor-drafts-select.active {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
/* Graceful exit when a project card is deleted — fade + shrink before re-render. */
|
||||
.gallery-editor-draft-card.gallery-draft-removing {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
transition: opacity 0.24s ease, transform 0.24s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Draft select dot uses the standard small .gallery-select-dot look (same as the
|
||||
photo grid) — no oversized white-ring checkbox. */
|
||||
.gallery-editor-drafts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.gallery-drafts-just-opened > .gallery-editor-draft-card {
|
||||
animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards;
|
||||
}
|
||||
.gallery-drafts-just-opened > :nth-child(1) { animation-delay: 0.02s; }
|
||||
.gallery-drafts-just-opened > :nth-child(2) { animation-delay: 0.04s; }
|
||||
.gallery-drafts-just-opened > :nth-child(3) { animation-delay: 0.06s; }
|
||||
.gallery-drafts-just-opened > :nth-child(4) { animation-delay: 0.08s; }
|
||||
.gallery-drafts-just-opened > :nth-child(5) { animation-delay: 0.10s; }
|
||||
.gallery-drafts-just-opened > :nth-child(6) { animation-delay: 0.12s; }
|
||||
.gallery-drafts-just-opened > :nth-child(7) { animation-delay: 0.14s; }
|
||||
.gallery-drafts-just-opened > :nth-child(8) { animation-delay: 0.16s; }
|
||||
.gallery-drafts-just-opened > :nth-child(9) { animation-delay: 0.18s; }
|
||||
.gallery-drafts-just-opened > :nth-child(10) { animation-delay: 0.20s; }
|
||||
.gallery-drafts-just-opened > :nth-child(11) { animation-delay: 0.22s; }
|
||||
.gallery-drafts-just-opened > :nth-child(12) { animation-delay: 0.24s; }
|
||||
.gallery-drafts-just-opened > :nth-child(13) { animation-delay: 0.26s; }
|
||||
.gallery-drafts-just-opened > :nth-child(14) { animation-delay: 0.28s; }
|
||||
.gallery-drafts-just-opened > :nth-child(15) { animation-delay: 0.30s; }
|
||||
.gallery-drafts-just-opened > :nth-child(16) { animation-delay: 0.32s; }
|
||||
.gallery-drafts-just-opened > :nth-child(17) { animation-delay: 0.34s; }
|
||||
.gallery-drafts-just-opened > :nth-child(18) { animation-delay: 0.36s; }
|
||||
.gallery-drafts-just-opened > :nth-child(19) { animation-delay: 0.38s; }
|
||||
.gallery-drafts-just-opened > :nth-child(20) { animation-delay: 0.40s; }
|
||||
.gallery-drafts-just-opened > :nth-child(n+21) { animation-delay: 0.42s; }
|
||||
.gallery-drafts-view-toggle {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.gallery-drafts-view-toggle button {
|
||||
width: 25px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.gallery-drafts-view-toggle button:last-child { border-right: 0; }
|
||||
.gallery-drafts-view-toggle button:hover { color: var(--fg); }
|
||||
.gallery-drafts-view-toggle button.active {
|
||||
height: 22px;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete {
|
||||
position: static;
|
||||
opacity: 0.55;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
.gallery-editor-draft-delete-trash { display: none; }
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-trash {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-error, #f44);
|
||||
background: color-mix(in srgb, var(--color-error, #f44) 10%, transparent);
|
||||
}
|
||||
.gallery-editor-draft-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.gallery-editor-draft-card:hover {
|
||||
border-color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
}
|
||||
.gallery-editor-draft-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
display: block;
|
||||
}
|
||||
.gallery-editor-draft-thumb-empty {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
color-mix(in srgb, var(--fg) 4%, transparent),
|
||||
color-mix(in srgb, var(--fg) 4%, transparent) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
}
|
||||
.gallery-editor-draft-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.gallery-editor-draft-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-editor-draft-meta {
|
||||
font-size: 10px;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-editor-draft-delete {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
}
|
||||
.gallery-editor-draft-delete {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.gallery-editor-draft-delete:hover { background: var(--red); }
|
||||
/* On very narrow phones, give project search its own row instead of
|
||||
compressing the title and view controls into an overflowing header. */
|
||||
@media (max-width: 360px) {
|
||||
.gallery-editor-drafts-header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-editor-drafts-title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.gallery-editor-drafts-search {
|
||||
order: 10;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
#gallery-editor-drafts-select {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
/* Editor layout */
|
||||
.gallery-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Prevent the editor's own scroll/touch from chaining out to the
|
||||
gallery modal or the page body — critical on mobile so touching
|
||||
the canvas doesn't slide the whole page. */
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
/* Pin the topbar to the top of the editor so its action buttons remain
|
||||
visible if the gallery modal body itself ends up scrolling. */
|
||||
.ge-topbar { position: sticky; top: 0; z-index: 5; }
|
||||
|
||||
/* Top bar */
|
||||
.ge-topbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.ge-topbar-left, .ge-topbar-right {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ge-topbar-right > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-topbar .dropdown[popover] {
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
bottom: auto;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ge-draft-status {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: color-mix(in srgb, var(--fg) 52%, transparent);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,20 @@
|
||||
/* Cookbook (including What Fits?), deep research, memory, settings and admin.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
/* Cookbook already shows its own running/served-status dot
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/* Document library and editor, image gallery, image editor.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
/* Document library search-term highlight — clear, high-contrast. */
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/* Email, calendar, notes and tasks panels.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
@keyframes email-notif-breathe {
|
||||
|
||||
+23
-6
@@ -246,15 +246,32 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260923csssplit1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
@@ -2648,7 +2665,7 @@
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20260921chatgptusage2"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
|
||||
@@ -137,7 +137,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| **`gallery.js`** / **`galleryEditor.js`** | Gallery/image library and canvas editor entry points. |
|
||||
| **`emailInbox.js`** / **`emailLibrary.js`** | Email inbox reader and library modal. Sub-modules handle signatures, reply recipients, state, and signature folding. |
|
||||
| **`emailInbox.js`** / **`emailLibrary/`** | Email inbox reader and library modal. `emailLibrary.js` is a re-export wrapper; `emailLibrary/index.js` owns the panel, list and cards, with `settingsPage.js`, `unsubscribe.js`, `reader.js`, `menus.js`, `bodyRender.js`, `attachments.js` and `aiReply.js` alongside it plus the `utils.js` / `signatureFold.js` / `replyRecipients.js` / `state.js` leaves. |
|
||||
| **`calendar.js`** / **`calendar/utils.js`** / **`calendar/reminders.js`** | Calendar views, event forms, reminders. |
|
||||
| **`tasks.js`** | Scheduled task/recurring LLM job UI. |
|
||||
| **`notes.js`** | Notes and todo panel, reminders, pinboard. |
|
||||
|
||||
+24
-11373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
// static/js/emailLibrary/aiReply.js
|
||||
//
|
||||
// The AI-reply entry points and the reader submenus that sit next to them:
|
||||
// reply-mode choice, the per-message context note (drafted into localStorage so
|
||||
// a half-typed instruction survives a reload), the translate submenu, and the
|
||||
// remind-me submenu that writes a todo note.
|
||||
//
|
||||
// The context-draft key is versioned (`…:v2:`) and scoped by account, folder
|
||||
// and uid, so a draft cannot leak from one message to another.
|
||||
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { topPortalZ } from '../toolWindowZOrder.js';
|
||||
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { state } from './state.js';
|
||||
import { _esc, _extractName } from './utils.js';
|
||||
import { _snapEmailModalToLeftSidebar, _translateEmail } from './index.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
export function _aiReplyIcon(data) {
|
||||
return '<svg class="email-ai-reply-icon" width="14" height="14" viewBox="0 0 24 24" fill="var(--accent-primary, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>';
|
||||
}
|
||||
|
||||
function _summaryIcon(data) {
|
||||
const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="${fill}"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>`;
|
||||
}
|
||||
|
||||
async function _emailTranslateLanguage() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/config`);
|
||||
const cfg = await res.json();
|
||||
return cfg?.email_translate_language || 'English';
|
||||
} catch (_) {
|
||||
return 'English';
|
||||
}
|
||||
}
|
||||
|
||||
async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') {
|
||||
_snapEmailModalToLeftSidebar(btn.closest('.modal'));
|
||||
btn.disabled = true;
|
||||
const orig = btn.innerHTML;
|
||||
let wp = null;
|
||||
try {
|
||||
wp = spinnerModule.createWhirlpool(14);
|
||||
wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
|
||||
btn.innerHTML = '';
|
||||
btn.appendChild(wp.element);
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (state._onEmailClick) {
|
||||
return await state._onEmailClick({ email: em, emailData: data, mode, noteHint });
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
try { wp && wp.stop(); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
}
|
||||
|
||||
const _AI_REPLY_CONTEXT_DRAFT_PREFIX = 'odysseus:email-ai-reply-context:v2:';
|
||||
let _aiReplyChoiceOutsideClose = null;
|
||||
|
||||
function _aiReplyContextDraftKey(em, data) {
|
||||
const accountId = data?.account_id || em?.account_id || state._libAccountId || '';
|
||||
const folder = data?.folder || em?.folder || state._libFolder || 'INBOX';
|
||||
const uid = data?.uid || em?.uid || data?.message_id || em?.message_id || '';
|
||||
if (!uid) return '';
|
||||
return _AI_REPLY_CONTEXT_DRAFT_PREFIX
|
||||
+ [accountId, folder, uid].map(value => encodeURIComponent(String(value || ''))).join(':');
|
||||
}
|
||||
|
||||
function _loadAiReplyContextDraft(key) {
|
||||
if (!key) return '';
|
||||
try { return localStorage.getItem(key) || ''; } catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function _saveAiReplyContextDraft(key, value) {
|
||||
if (!key) return;
|
||||
try {
|
||||
const text = String(value || '');
|
||||
if (text.trim()) localStorage.setItem(key, text);
|
||||
else localStorage.removeItem(key);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _clearAiReplyContextDraft(key) {
|
||||
if (!key) return;
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
}
|
||||
|
||||
function _closeAiReplyChoice() {
|
||||
if (_aiReplyChoiceOutsideClose) {
|
||||
document.removeEventListener('click', _aiReplyChoiceOutsideClose, true);
|
||||
_aiReplyChoiceOutsideClose = null;
|
||||
}
|
||||
document.querySelectorAll('.email-ai-reply-choice').forEach(el => {
|
||||
const input = el.querySelector('[data-note-input]');
|
||||
_saveAiReplyContextDraft(el.dataset.contextDraftKey || '', input?.value || '');
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function _showAiReplyChoice(btn, em, data, positionAnchor = btn) {
|
||||
_closeAiReplyChoice();
|
||||
const rect = positionAnchor.getBoundingClientRect();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'email-ai-reply-choice';
|
||||
/* Clamp width to viewport minus 16px margin so the menu never spills off
|
||||
the right edge on narrow mobile screens. */
|
||||
const menuMaxW = Math.min(220, window.innerWidth - 16);
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
|
||||
/* Vertical placement: prefer below the button, but flip above if
|
||||
there's not enough room (e.g. button near bottom of viewport).
|
||||
Estimated menu height is ~150px (textarea + buttons + padding). */
|
||||
const estHeight = 150;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
let top;
|
||||
if (spaceBelow >= estHeight || spaceBelow >= spaceAbove) {
|
||||
top = Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8));
|
||||
} else {
|
||||
top = Math.max(8, rect.top - estHeight - 6);
|
||||
}
|
||||
menu.style.cssText = [
|
||||
'position:fixed',
|
||||
`left:${left}px`,
|
||||
`top:${top}px`,
|
||||
`max-width:${menuMaxW}px`,
|
||||
`max-height:${window.innerHeight - 16}px`,
|
||||
'overflow:auto',
|
||||
'box-sizing:border-box',
|
||||
`z-index:${topPortalZ()}`,
|
||||
'display:flex',
|
||||
'gap:6px',
|
||||
'padding:6px',
|
||||
'background:var(--bg,#111)',
|
||||
'border:1px solid var(--border,#333)',
|
||||
'border-radius:7px',
|
||||
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
|
||||
].join(';');
|
||||
menu.innerHTML = `
|
||||
<div class="email-ai-reply-row" style="display:flex;flex-direction:column;gap:6px;min-width:180px;">
|
||||
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const noteInput = menu.querySelector('[data-note-input]');
|
||||
const contextDraftKey = _aiReplyContextDraftKey(em, data);
|
||||
menu.dataset.contextDraftKey = contextDraftKey;
|
||||
noteInput.value = _loadAiReplyContextDraft(contextDraftKey);
|
||||
noteInput.addEventListener('input', () => {
|
||||
_saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
|
||||
});
|
||||
setTimeout(() => noteInput.focus(), 0);
|
||||
menu.addEventListener('click', async (ev) => {
|
||||
const choice = ev.target.closest('[data-mode]');
|
||||
if (!choice) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||
const noteHint = (noteInput.value || '').trim();
|
||||
_saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
|
||||
_closeAiReplyChoice();
|
||||
const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint);
|
||||
if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey);
|
||||
});
|
||||
// Esc closes the popover; ignore plain clicks inside the menu so the
|
||||
// textarea stays focused.
|
||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||
document.body.appendChild(menu);
|
||||
// Outside-click closer: only fires when the click target is OUTSIDE
|
||||
// the menu. The original handler closed on any click which made
|
||||
// focusing the textarea immediately dismiss the popover.
|
||||
const outsideClose = (ev) => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
document.removeEventListener('click', outsideClose, true);
|
||||
if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null;
|
||||
_closeAiReplyChoice();
|
||||
};
|
||||
_aiReplyChoiceOutsideClose = outsideClose;
|
||||
setTimeout(() => {
|
||||
if (_aiReplyChoiceOutsideClose === outsideClose) {
|
||||
document.addEventListener('click', outsideClose, true);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) {
|
||||
ev.stopPropagation();
|
||||
const btn = ev.currentTarget;
|
||||
// First click on a cached email surfaces the cached draft. Second
|
||||
// click clears the cache and opens the Fast/Full + context menu so
|
||||
// the user can ask for a fresh draft (with new steering).
|
||||
if (data?.cached_ai_reply && !btn.dataset.shownOnce) {
|
||||
btn.dataset.shownOnce = '1';
|
||||
_runAiReplyFromButton(btn, em, data, 'ai-reply');
|
||||
return;
|
||||
}
|
||||
if (data?.cached_ai_reply) {
|
||||
data.cached_ai_reply = null;
|
||||
btn.dataset.shownOnce = '';
|
||||
}
|
||||
_showAiReplyChoice(btn, em, data, positionAnchor);
|
||||
}
|
||||
|
||||
export function _hasMultipleRecipients(data) {
|
||||
// Count distinct addresses in To + Cc (minus the current user). Empty
|
||||
// fallback when the user's address isn't yet known — no exclusion.
|
||||
const myAddress = (window._myEmailAddress || '').toLowerCase();
|
||||
const extractEmails = (str) => {
|
||||
if (!str) return [];
|
||||
return str.split(',')
|
||||
.map(s => {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
return (m ? m[1] : s).trim().toLowerCase();
|
||||
})
|
||||
.filter(e => e && e !== myAddress);
|
||||
};
|
||||
const recipients = new Set([
|
||||
...extractEmails(data.to),
|
||||
...extractEmails(data.cc),
|
||||
]);
|
||||
// Sender counts as one other person too
|
||||
if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
|
||||
recipients.add(data.from_address.toLowerCase());
|
||||
}
|
||||
return recipients.size > 1;
|
||||
}
|
||||
|
||||
// _esc lives in ./emailLibrary/utils.js
|
||||
|
||||
export function _showEmailTranslateSubmenu(reader, parentDropdown) {
|
||||
parentDropdown.innerHTML = '';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'dropdown-item-compact';
|
||||
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
|
||||
header.innerHTML = '<span>Translate to</span>';
|
||||
parentDropdown.appendChild(header);
|
||||
|
||||
const customRow = document.createElement('div');
|
||||
customRow.className = 'dropdown-item-compact email-translate-custom-row';
|
||||
customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;';
|
||||
customRow.innerHTML = `
|
||||
<input type="text" class="email-translate-custom-input" placeholder="Write language..." style="min-width:0;width:136px;height:26px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);font:inherit;font-size:11px;padding:0 7px;">
|
||||
<button type="button" class="email-translate-custom-go" style="height:26px;padding:0 8px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 5%, transparent);color:inherit;font:inherit;font-size:11px;cursor:pointer;">Go</button>
|
||||
`;
|
||||
parentDropdown.appendChild(customRow);
|
||||
const input = customRow.querySelector('.email-translate-custom-input');
|
||||
const go = customRow.querySelector('.email-translate-custom-go');
|
||||
const runCustom = async () => {
|
||||
const language = (input?.value || '').trim();
|
||||
if (!language) {
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
parentDropdown.remove();
|
||||
await _translateEmail(reader, language);
|
||||
};
|
||||
customRow.addEventListener('click', e => e.stopPropagation());
|
||||
go?.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
await runCustom();
|
||||
});
|
||||
input?.addEventListener('keydown', async e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await runCustom();
|
||||
}
|
||||
});
|
||||
_emailTranslateLanguage().then(language => {
|
||||
if (input && !input.value) input.value = language || 'English';
|
||||
}).catch(() => {});
|
||||
|
||||
const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German'];
|
||||
for (const language of languages) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dropdown-item-compact';
|
||||
item.innerHTML = `<span>${language}</span>`;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
await _translateEmail(reader, language);
|
||||
});
|
||||
parentDropdown.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reminder submenu (used by both email menus) ----
|
||||
export function _showLibRemindSubmenu(em, parentDropdown) {
|
||||
parentDropdown.innerHTML = '';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'dropdown-item-compact';
|
||||
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
|
||||
header.innerHTML = '<span>Remind me</span>';
|
||||
parentDropdown.appendChild(header);
|
||||
|
||||
const now = new Date();
|
||||
const laterToday = new Date(now);
|
||||
const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
|
||||
if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
|
||||
else laterToday.setTime(sixPm.getTime());
|
||||
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
|
||||
const daysUntilMon = (8 - now.getDay()) % 7 || 7;
|
||||
const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
|
||||
|
||||
const presets = [
|
||||
{ label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
|
||||
{ label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
|
||||
{ label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
|
||||
];
|
||||
for (const p of presets) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dropdown-item-compact';
|
||||
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
await _createEmailReplyReminder(em, p.date);
|
||||
});
|
||||
parentDropdown.appendChild(item);
|
||||
}
|
||||
const customItem = document.createElement('div');
|
||||
customItem.className = 'dropdown-item-compact';
|
||||
customItem.innerHTML = '<span>Pick date and time…</span>';
|
||||
customItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'datetime-local';
|
||||
const def = new Date(tomorrow);
|
||||
const pad = n => String(n).padStart(2,'0');
|
||||
tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
|
||||
tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
|
||||
document.body.appendChild(tmp);
|
||||
tmp.focus();
|
||||
if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
|
||||
tmp.addEventListener('change', async () => {
|
||||
if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
|
||||
tmp.remove();
|
||||
});
|
||||
tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
|
||||
});
|
||||
parentDropdown.appendChild(customItem);
|
||||
// "Note" — prompts for free-text and saves it as a note without a
|
||||
// due_date, so no timer/reminder fires.
|
||||
const noteItem = document.createElement('div');
|
||||
noteItem.className = 'dropdown-item-compact';
|
||||
noteItem.innerHTML = '<span>Note</span>';
|
||||
noteItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
_promptEmailNote(em);
|
||||
});
|
||||
parentDropdown.appendChild(noteItem);
|
||||
}
|
||||
|
||||
function _promptEmailNote(em) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;';
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);';
|
||||
const subject = em.subject || '(no subject)';
|
||||
card.innerHTML = `
|
||||
<div style="font-size:11px;opacity:0.6;">Note about ${_esc(subject)}</div>
|
||||
<textarea data-note placeholder="Write your note…" rows="4" style="resize:vertical;min-height:80px;font-family:inherit;font-size:12px;padding:7px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev,#1a1a1a);color:var(--fg);box-sizing:border-box;width:100%;"></textarea>
|
||||
<div class="email-note-actions" style="display:flex;gap:6px;justify-content:flex-end;position:relative;top:4px;">
|
||||
<button class="memory-toolbar-btn" data-act="cancel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button>
|
||||
<button class="memory-toolbar-btn active" data-act="save"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><path d="M4 4h16v16H4z"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>Save</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
const ta = card.querySelector('[data-note]');
|
||||
setTimeout(() => ta.focus(), 0);
|
||||
const close = () => overlay.remove();
|
||||
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
|
||||
card.querySelector('[data-act="cancel"]').addEventListener('click', close);
|
||||
card.querySelector('[data-act="save"]').addEventListener('click', async () => {
|
||||
const text = (ta.value || '').trim();
|
||||
if (!text) { ta.focus(); return; }
|
||||
close();
|
||||
await _createEmailReplyReminder(em, null, text);
|
||||
});
|
||||
ta.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') close();
|
||||
else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click();
|
||||
});
|
||||
}
|
||||
|
||||
async function _createEmailReplyReminder(em, dueDate, customText = '') {
|
||||
const pad = n => String(n).padStart(2,'0');
|
||||
const iso = dueDate
|
||||
? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`
|
||||
: null;
|
||||
const fullFrom = em.from || em.sender || '';
|
||||
// Extract just the first name from "First Last <email@x>" or fall back to email local part
|
||||
let from = 'someone';
|
||||
if (fullFrom) {
|
||||
const fullName = _extractName(fullFrom);
|
||||
if (fullName) {
|
||||
// Strip quotes, take the first whitespace-separated word, capitalize
|
||||
const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
|
||||
if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
|
||||
}
|
||||
}
|
||||
const subject = em.subject || '(no subject)';
|
||||
const folder = state._libFolder || 'INBOX';
|
||||
const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
|
||||
const itemText = customText || `Reply to ${from}: ${subject}`;
|
||||
const payload = {
|
||||
title: `Reply: ${subject}`,
|
||||
note_type: 'todo',
|
||||
items: [
|
||||
{ text: itemText, checked: false },
|
||||
],
|
||||
content: `Open email: ${deepLink}`,
|
||||
label: 'email reminder',
|
||||
source: 'email',
|
||||
};
|
||||
if (iso) payload.due_date = iso;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/notes`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const { showToast } = await import('../ui.js?v=20260916largetoolscroll1');
|
||||
if (dueDate) {
|
||||
const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
|
||||
showToast(`Todo reminder set for ${fmt}`);
|
||||
} else {
|
||||
showToast('Reply note saved');
|
||||
}
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
try { Notification.requestPermission(); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
const { showError } = await import('../ui.js?v=20260916largetoolscroll1');
|
||||
showError('Failed to create reminder');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// static/js/emailLibrary/attachments.js
|
||||
//
|
||||
// Attachment chips in a reader: building the markup, wiring open/download/
|
||||
// "open in editor" per type, and the deferred load for messages whose
|
||||
// attachment list was not in the list response.
|
||||
//
|
||||
// The deferred path exists because `/api/email/list` does not carry attachment
|
||||
// metadata: a card can know it *has* attachments without knowing what they
|
||||
// are, so the chips are rendered late and the card icon repaired afterwards.
|
||||
|
||||
import spinnerModule from '../spinner.js';
|
||||
import * as Modals from '../modalManager.js';
|
||||
import { state } from './state.js';
|
||||
import { _esc } from './utils.js';
|
||||
import { _acct, _openCalendarEventFromEmail, _prepareEmailWindowForDocument } from './index.js';
|
||||
import { _openEmailAsTab, _openEmailWindow } from './reader.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
// Wire click handlers for attachment chips + "open in editor" sub-buttons
|
||||
// inside a reader. Safe to call multiple times — uses dataset.wired flag to
|
||||
// skip nodes that already have listeners.
|
||||
function _wireAttachmentHandlers(reader, folder) {
|
||||
const useFolder = folder || state._libFolder;
|
||||
// Detect mobile here so the attachment-chip handler doesn't blow up with
|
||||
// a ReferenceError when this fn is called from contexts that don't have
|
||||
// _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
|
||||
const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
|
||||
if (btn.dataset.wired === '1') return;
|
||||
btn.dataset.wired = '1';
|
||||
btn.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (btn.dataset.downloading === '1') return;
|
||||
const uid = btn.dataset.attUid;
|
||||
const sourceFolder = btn.dataset.attFolder || useFolder;
|
||||
const count = Number(btn.dataset.attCount || 0);
|
||||
if (!uid) return;
|
||||
const originalHtml = btn.innerHTML;
|
||||
const originalTitle = btn.title;
|
||||
btn.dataset.downloading = '1';
|
||||
btn.classList.add('is-loading');
|
||||
try {
|
||||
const sp = window.spinnerModule || (await import('../spinner.js')).default;
|
||||
const wp = sp.createWhirlpool(12);
|
||||
wp.element.style.margin = '0';
|
||||
btn.textContent = '';
|
||||
btn.appendChild(wp.element);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'All';
|
||||
btn.appendChild(label);
|
||||
} catch (_) {
|
||||
btn.textContent = 'All...';
|
||||
}
|
||||
try {
|
||||
const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => '');
|
||||
console.error('attachments zip download failed', res.status, msg);
|
||||
location.href = url;
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `email-${uid}-attachments.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
|
||||
} catch (e) {
|
||||
console.error('attachments zip download error', e);
|
||||
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {}
|
||||
} finally {
|
||||
delete btn.dataset.downloading;
|
||||
btn.classList.remove('is-loading');
|
||||
btn.title = originalTitle;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
});
|
||||
reader.querySelectorAll('.email-attachment-calendar-open').forEach(openBtn => {
|
||||
if (openBtn.dataset.wired === '1') return;
|
||||
openBtn.dataset.wired = '1';
|
||||
openBtn.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (openBtn.dataset.opening === '1') return;
|
||||
const uid = openBtn.dataset.openUid;
|
||||
const index = openBtn.dataset.openIndex;
|
||||
const name = openBtn.dataset.openName || `calendar-${index}`;
|
||||
const sourceFolder = openBtn.dataset.openFolder || useFolder;
|
||||
if (!uid || index == null) return;
|
||||
openBtn.dataset.opening = '1';
|
||||
openBtn.classList.add('is-loading');
|
||||
const originalHtml = openBtn.innerHTML;
|
||||
try {
|
||||
const wp = spinnerModule.createWhirlpool(12);
|
||||
wp.element.style.margin = '0';
|
||||
openBtn.textContent = '';
|
||||
openBtn.appendChild(wp.element);
|
||||
const folderQs = encodeURIComponent(sourceFolder);
|
||||
const attachmentUrl = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`;
|
||||
const attachmentRes = await fetch(attachmentUrl, { credentials: 'same-origin' });
|
||||
if (!attachmentRes.ok) throw new Error(`HTTP ${attachmentRes.status}`);
|
||||
const blob = await attachmentRes.blob();
|
||||
const fd = new FormData();
|
||||
fd.append('file', blob, name);
|
||||
const importRes = await fetch(`${API_BASE}/api/calendar/import`, {
|
||||
method: 'POST', body: fd, credentials: 'same-origin',
|
||||
});
|
||||
const result = await importRes.json().catch(() => ({}));
|
||||
if (!importRes.ok || !result.ok) {
|
||||
throw new Error(result.detail || result.error || `HTTP ${importRes.status}`);
|
||||
}
|
||||
const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : '';
|
||||
if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) {
|
||||
_openCalendarEventFromEmail(existingEventUid);
|
||||
}
|
||||
try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('calendar-refresh'));
|
||||
} catch (e) {
|
||||
console.error('calendar attachment import failed', e);
|
||||
try {
|
||||
const { showError } = await import('../ui.js?v=20260916largetoolscroll1');
|
||||
showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`);
|
||||
} catch (_) {}
|
||||
} finally {
|
||||
delete openBtn.dataset.opening;
|
||||
openBtn.classList.remove('is-loading');
|
||||
openBtn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
});
|
||||
reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
|
||||
if (openBtn.dataset.wired === '1') return;
|
||||
openBtn.dataset.wired = '1';
|
||||
openBtn.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (openBtn.dataset.opening === '1') return;
|
||||
const uid = openBtn.dataset.openUid;
|
||||
const index = openBtn.dataset.openIndex;
|
||||
const name = openBtn.dataset.openName || `attachment-${index}`;
|
||||
const sourceFolder = openBtn.dataset.openFolder || useFolder;
|
||||
if (!uid || index == null) return;
|
||||
openBtn.dataset.opening = '1';
|
||||
openBtn.classList.add('is-loading');
|
||||
const origHtml = openBtn.innerHTML;
|
||||
const wp = spinnerModule.createWhirlpool(12);
|
||||
wp.element.style.margin = '0';
|
||||
openBtn.textContent = '';
|
||||
openBtn.appendChild(wp.element);
|
||||
try {
|
||||
const folderQs = encodeURIComponent(sourceFolder);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 55000);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(
|
||||
`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
|
||||
{ method: 'POST', credentials: 'same-origin', signal: controller.signal }
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.doc_id) {
|
||||
const msg = (json && json.error) || `HTTP ${res.status}`;
|
||||
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Tab the email modal down only when the viewport cannot fit both
|
||||
// Email and the document pane. Desktop keeps a side-by-side layout
|
||||
// when there is room; mobile still gives the document the screen.
|
||||
const ownerModal = openBtn.closest('.modal');
|
||||
if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) {
|
||||
try {
|
||||
const ok = Modals.minimize(ownerModal.id);
|
||||
if (!ok) ownerModal.classList.add('hidden');
|
||||
} catch (_) {
|
||||
ownerModal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
const docMod = await import('../document.js?v=20260916docctx2');
|
||||
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
|
||||
if (typeof load === 'function') {
|
||||
await load(json.doc_id);
|
||||
} else {
|
||||
location.href = `/?doc=${encodeURIComponent(json.doc_id)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Open document failed:', e);
|
||||
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('attachment-as-doc error', e);
|
||||
const msg = e && e.name === 'AbortError'
|
||||
? `Opening ${name} timed out. Try downloading it instead.`
|
||||
: `Couldn't open ${name}`;
|
||||
try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {}
|
||||
} finally {
|
||||
delete openBtn.dataset.opening;
|
||||
openBtn.classList.remove('is-loading');
|
||||
openBtn.innerHTML = origHtml;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
reader.querySelectorAll('.email-attachment-download').forEach(downloadBtn => {
|
||||
if (downloadBtn.dataset.wired === '1') return;
|
||||
downloadBtn.dataset.wired = '1';
|
||||
downloadBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const chip = downloadBtn.closest('.email-attachment-chip');
|
||||
if (!chip) return;
|
||||
// Reuse the established attachment download handler below. Marking the
|
||||
// chip expanded bypasses its old first-click reveal behavior.
|
||||
chip.classList.add('is-expanded');
|
||||
chip.dataset.downloadTrigger = 'button';
|
||||
chip.click();
|
||||
});
|
||||
downloadBtn.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
ev.preventDefault();
|
||||
downloadBtn.click();
|
||||
});
|
||||
});
|
||||
|
||||
reader.querySelectorAll('.email-attachment-chip').forEach(chip => {
|
||||
if (chip.dataset.wired === '1') return;
|
||||
chip.dataset.wired = '1';
|
||||
chip.addEventListener('click', async (ev) => {
|
||||
if (ev.target.closest('.email-attachment-open, .email-attachment-calendar-open, .email-attachment-download')) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const uid = chip.dataset.attUid;
|
||||
const index = chip.dataset.attIndex;
|
||||
const name = chip.dataset.attName || `attachment-${index}`;
|
||||
const sourceFolder = chip.dataset.attFolder || useFolder;
|
||||
if (!uid || index == null) return;
|
||||
if (!chip.classList.contains('is-expanded')) {
|
||||
reader.querySelectorAll('.email-attachment-chip.is-expanded').forEach(other => {
|
||||
if (other !== chip) other.classList.remove('is-expanded');
|
||||
});
|
||||
chip.classList.add('is-expanded');
|
||||
return;
|
||||
}
|
||||
const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
|
||||
if (_isMobileUA) {
|
||||
delete chip.dataset.downloadTrigger;
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
}
|
||||
// Swap the paperclip icon for a whirlpool spinner while the
|
||||
// download is in flight, so large attachments give a clear cue
|
||||
// they're loading. Restore on completion.
|
||||
const buttonTriggered = chip.dataset.downloadTrigger === 'button';
|
||||
const iconSvg = buttonTriggered
|
||||
? chip.querySelector('.email-attachment-download > svg')
|
||||
: chip.querySelector(':scope > svg');
|
||||
const origIconHtml = iconSvg ? iconSvg.outerHTML : '';
|
||||
let _wp = null;
|
||||
let _spinnerHost = null;
|
||||
try {
|
||||
const sp = window.spinnerModule || (await import('../spinner.js')).default;
|
||||
_wp = sp.createWhirlpool(12);
|
||||
_spinnerHost = document.createElement('span');
|
||||
_spinnerHost.className = 'email-attachment-spinner';
|
||||
_spinnerHost.style.cssText = 'display:inline-flex;width:12px;height:12px;align-items:center;justify-content:center;flex-shrink:0;position:relative;top:-2px;';
|
||||
_spinnerHost.appendChild(_wp.element);
|
||||
if (iconSvg) iconSvg.replaceWith(_spinnerHost);
|
||||
} catch (_) {}
|
||||
const origOpacity = chip.style.opacity;
|
||||
chip.style.opacity = '0.85';
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
console.error('attachment download failed', res.status, await res.text().catch(() => ''));
|
||||
location.href = url;
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
} catch (e) {
|
||||
console.error('attachment download error', e);
|
||||
location.href = url;
|
||||
} finally {
|
||||
delete chip.dataset.downloadTrigger;
|
||||
chip.style.opacity = origOpacity;
|
||||
if (_spinnerHost && _spinnerHost.parentNode && origIconHtml) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = origIconHtml;
|
||||
const restored = tmp.firstChild;
|
||||
if (restored) _spinnerHost.replaceWith(restored);
|
||||
}
|
||||
if (_wp) { try { _wp.destroy(); } catch (_) {} }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Heuristic: skip "attachments" that are clearly inline images used by
|
||||
// signatures / quoted-reply headers (small image files, Outlook-style
|
||||
// image001.png placeholders, logo*.png, etc.). They aren't real user-
|
||||
// shared attachments and adding them to the chips makes every email look
|
||||
// like it has content the user needs to act on.
|
||||
function _isLikelySignatureImage(a) {
|
||||
if (!a || !a.filename) return false;
|
||||
const name = String(a.filename).toLowerCase();
|
||||
const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name);
|
||||
if (!isImage) return false;
|
||||
const size = Number(a.size) || 0;
|
||||
// Outlook / Gmail inline image placeholders always look like this.
|
||||
if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true;
|
||||
if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true;
|
||||
// Most signature logos / inline thumbnails are < 30 KB. Real user-
|
||||
// shared images (screenshots, photos) are typically 50 KB+.
|
||||
if (size > 0 && size < 30 * 1024) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the attachments header+chips HTML for an email read response. Pulled
|
||||
// out so both the initial-open and the swap-reader paths can render it.
|
||||
export function _buildAttsHtmlFor(uid, data) {
|
||||
if (!data) return '';
|
||||
const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
|
||||
const _CALENDAR_RE = /\.(calendar|ics|ical)$/i;
|
||||
const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
|
||||
if (!currentAttachments.length && !relatedAttachments.length) return '';
|
||||
const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
|
||||
const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
|
||||
const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
|
||||
if (!visible.length && !related.length && state._libViewInlineImages === false) return '';
|
||||
const renderChip = (a, extraClass = '') => {
|
||||
const calendarAttachment = _CALENDAR_RE.test(a.filename || '');
|
||||
const openable = _OPENABLE_RE.test(a.filename || '');
|
||||
const chipUid = a.source_uid || a.uid || uid;
|
||||
const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
|
||||
const openBtn = calendarAttachment
|
||||
? `<span class="email-attachment-calendar-open" role="button" tabindex="0" aria-label="Add to calendar" title="Add to calendar" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="m8 15 2 2 5-5"/></svg></span>`
|
||||
: openable
|
||||
? `<span class="email-attachment-open" role="button" tabindex="0" aria-label="Open in document editor" title="Open in document editor" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg></span>`
|
||||
: '';
|
||||
const downloadBtn = `<span class="email-attachment-download" role="button" tabindex="0" aria-label="Download attachment" title="Download attachment"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>`;
|
||||
return `<button type="button" class="email-attachment-chip${extraClass}" data-att-uid="${_esc(chipUid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}" data-att-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}${downloadBtn}</button>`;
|
||||
};
|
||||
const chips = visible.map(a => renderChip(a)).join('');
|
||||
const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
|
||||
const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
|
||||
const visibleSection = visible.length
|
||||
? '<div class="email-reader-atts">' + chips + '</div>'
|
||||
: '';
|
||||
const relatedSection = related.length
|
||||
? '<div class="email-reader-atts-hidden-note">From earlier in this thread</div><div class="email-reader-atts email-reader-atts-related">' + relatedChips + '</div>'
|
||||
: '';
|
||||
const hiddenSection = hidden.length && state._libViewInlineImages !== false
|
||||
? '<div class="email-reader-atts-hidden-note">Filtered inline images / signature files</div><div class="email-reader-atts email-reader-atts-hidden">' + hiddenChips + '</div>'
|
||||
: '';
|
||||
const label = visible.length
|
||||
? `Attachments (${visible.length + related.length})`
|
||||
: related.length
|
||||
? `Thread attachments (${related.length})`
|
||||
: state._libViewInlineImages === false
|
||||
? 'Attachments'
|
||||
: `Hidden inline attachments (${hidden.length})`;
|
||||
const startCollapsed = !visible.length && !related.length;
|
||||
const downloadAllBtn = visible.length > 4
|
||||
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>All</span></button>`
|
||||
: '';
|
||||
return (
|
||||
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
|
||||
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
|
||||
+ `<span>${label}</span>`
|
||||
+ downloadAllBtn
|
||||
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</div>'
|
||||
+ visibleSection
|
||||
+ relatedSection
|
||||
+ hiddenSection
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) {
|
||||
if (!data) return data;
|
||||
const current = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
|
||||
const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length);
|
||||
if (!shouldFetch) return data;
|
||||
try {
|
||||
const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`);
|
||||
const meta = await metaRes.json().catch(() => ({}));
|
||||
if (metaRes.ok && Array.isArray(meta.attachments)) {
|
||||
return {
|
||||
...data,
|
||||
attachments: meta.attachments,
|
||||
related_attachments: related,
|
||||
attachments_deferred: false,
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
// Do not full-fetch the message during ordinary open. That can download and
|
||||
// parse large MIME bodies immediately after the reader renders, keeping IMAP
|
||||
// busy and making the UI feel stuck. Full attachment lookup is still available
|
||||
// from explicit attachment/download/forward actions.
|
||||
return data;
|
||||
}
|
||||
|
||||
export function _wireEmailAttachmentWrap(reader, folder) {
|
||||
if (!reader) return;
|
||||
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
if (attsWrap && !attsWrap.dataset.wired) {
|
||||
attsWrap.dataset.wired = '1';
|
||||
const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
|
||||
if (attsToggle) {
|
||||
attsToggle.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
attsWrap.classList.toggle('collapsed');
|
||||
});
|
||||
attsToggle.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
attsWrap.classList.toggle('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
try { _wireAttachmentHandlers(reader, folder); } catch {}
|
||||
}
|
||||
|
||||
export function _loadDeferredAttachmentsIntoReader(reader, uid, folder, data, knownHasAttachments = false) {
|
||||
if (!reader || !uid || !data) return;
|
||||
const current = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
|
||||
if (!data.attachments_deferred && !(knownHasAttachments && !current.length && !related.length)) return;
|
||||
const body = reader.querySelector('.email-reader-body');
|
||||
if (!body) return;
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'email-reader-atts-wrap email-reader-atts-loading';
|
||||
loading.style.cssText = 'min-height:34px;display:flex;flex-direction:row;align-items:center;gap:7px;padding:6px 14px;color:var(--fg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;opacity:.7;';
|
||||
const loadingSpinner = spinnerModule.createWhirlpool(12);
|
||||
loadingSpinner.element.style.margin = '0';
|
||||
loading.appendChild(loadingSpinner.element);
|
||||
loading.appendChild(document.createTextNode('Loading attachments…'));
|
||||
const existingWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
if (existingWrap) existingWrap.replaceWith(loading);
|
||||
else body.insertAdjacentElement('beforebegin', loading);
|
||||
_ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => {
|
||||
if (!reader.isConnected || !fullData) return;
|
||||
loading.remove();
|
||||
try { loadingSpinner.destroy(); } catch (_) {}
|
||||
const visibleCurrent = (fullData.attachments || []).filter(a => !_isLikelySignatureImage(a));
|
||||
const matchingEmail = (state._libEmails || []).find(em => (
|
||||
String(em.uid) === String(uid)
|
||||
&& String(em.account_id || state._libAccountId || '') === String(reader.closest('[data-email-account]')?.dataset.emailAccount || state._libAccountId || '')
|
||||
));
|
||||
if (matchingEmail) matchingEmail.has_attachments = visibleCurrent.length > 0;
|
||||
if (!visibleCurrent.length) {
|
||||
reader.closest('.doclib-card')?.querySelector('.email-card-attachment')?.remove();
|
||||
}
|
||||
const attsHtml = _buildAttsHtmlFor(uid, fullData);
|
||||
if (!attsHtml) return;
|
||||
const oldWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
if (oldWrap) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = attsHtml;
|
||||
oldWrap.replaceWith(tmp.firstElementChild);
|
||||
} else {
|
||||
body.insertAdjacentHTML('beforebegin', attsHtml);
|
||||
}
|
||||
_wireEmailAttachmentWrap(reader, folder);
|
||||
}).catch(() => {
|
||||
loading.remove();
|
||||
try { loadingSpinner.destroy(); } catch (_) {}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
// static/js/emailLibrary/bodyRender.js
|
||||
//
|
||||
// Turning a fetched message into reader HTML: the plain and threaded body
|
||||
// paths, inline MIME image handling, and the quote/thread folding that sits on
|
||||
// top of the detectors in ./signatureFold.js.
|
||||
//
|
||||
// Everything here writes into a reader element via innerHTML, so every branch
|
||||
// that touches server-supplied HTML goes through `_sanitizeHtml` from
|
||||
// ./utils.js first. `tests/test_security_regressions.py` counts those call
|
||||
// sites against the raw per-turn HTML reads, package-wide — so do not name that
|
||||
// property in a comment, or the count stops matching.
|
||||
|
||||
import {
|
||||
_QUOTE_ICON,
|
||||
_SIG_ICON,
|
||||
_extractQuoteMeta,
|
||||
_extractTurnMetaFromBlockquote,
|
||||
_foldSignature,
|
||||
_foldSummary,
|
||||
_harvestAttribution,
|
||||
_isBloatedSig,
|
||||
_looksLikeSignature,
|
||||
} from './signatureFold.js';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
_SIG_BLOAT_MIN_CHARS,
|
||||
_TALON_ORIG_RE,
|
||||
_TALON_WROTE,
|
||||
_esc,
|
||||
_escLinkify,
|
||||
_formatBubbleDate,
|
||||
_formatRecipients,
|
||||
_initials,
|
||||
_parseTurnMeta,
|
||||
_sanitizeHtml,
|
||||
_senderColor,
|
||||
} from './utils.js';
|
||||
import { _disableInlineImages } from './settingsPage.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
/**
|
||||
* Wrap a probable signature block in a collapsed <details> so it stops
|
||||
* eating the whole reader. We try, in priority order:
|
||||
* 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is
|
||||
* explicit, no guessing required. Same for Apple Mail's data-smartmail.
|
||||
* 2. The standard "-- " RFC 3676 sig delimiter.
|
||||
* 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own
|
||||
* line — fuzzier, but catches sigs without the dash marker.
|
||||
* 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client
|
||||
* boilerplate.
|
||||
* Anything matched gets wrapped from the marker through end-of-body.
|
||||
*/
|
||||
/**
|
||||
* Render the email body with sig/quote folds. If the backend has cached
|
||||
* LLM-detected boundary offsets (data.boundaries), use those for an exact
|
||||
* fold based on plain-text positions. Otherwise fall back to the regex
|
||||
* detectors. The plain-body branch is always preferred when boundaries
|
||||
* exist because the offsets are computed against plain text.
|
||||
*/
|
||||
// Global escape hatch — when the server's thread parser misfires (it
|
||||
// occasionally splits a single reply into two bogus "turns" by treating a
|
||||
// signature/disclaimer as its own message), the user can flip this off to
|
||||
// fall back to plain rendering. Survives reloads.
|
||||
const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled';
|
||||
// Threaded chat-bubble email view is DISABLED for now — too buggy to
|
||||
// ship. Force plain-text rendering everywhere by always returning true.
|
||||
// Re-enable by restoring the localStorage-backed body + the toggle
|
||||
// menu item in the reader's More menu.
|
||||
function _bubblesDisabled() {
|
||||
return true;
|
||||
}
|
||||
function _setBubblesDisabled(v) {
|
||||
try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {}
|
||||
}
|
||||
|
||||
export function _renderEmailBody(data) {
|
||||
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
|
||||
const folder = String(data?.folder || '').toLowerCase();
|
||||
const isSentFolder = folder.includes('sent');
|
||||
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
|
||||
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
|
||||
|
||||
// Messages authored by the user (Sent folder or self-sent copies in INBOX)
|
||||
// are current authored text. Do not let cached boundaries or HTML
|
||||
// blockquote parsing hide the whole thing behind "Earlier reply".
|
||||
if ((isSentFolder || isMine) && plain) {
|
||||
const plainTurns = _renderPlaintextThread(plain);
|
||||
if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
|
||||
return _foldSignature(plainTurns, null);
|
||||
}
|
||||
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
|
||||
}
|
||||
|
||||
// Prefer the normalized plain-text thread when the message contains
|
||||
// explicit reply markers. HTML mail from Outlook/Gmail commonly wraps the
|
||||
// same quoted message in several nested blockquotes; feeding that markup
|
||||
// to the HTML walker creates phantom chains such as "Earlier reply >
|
||||
// Earlier reply > sender". The plain representation has the actual quote
|
||||
// levels and gives us one stable fold per real quoted message.
|
||||
const hasPlainThreadMarkers = plain && (
|
||||
/^\s*>/m.test(plain)
|
||||
|| /^\s*-{5,}\s*(?:Previous message|Original message)\s*-{5,}\s*$/im.test(plain)
|
||||
|| /^\s*On\s.+?\s(?:wrote|skrev|schrieb|écrit|escribió)\s*:\s*$/im.test(plain)
|
||||
);
|
||||
if (hasPlainThreadMarkers) {
|
||||
const plainThread = _renderPlaintextThread(plain);
|
||||
if (plainThread) return _foldSignature(plainThread, data && data.sender_signature || null);
|
||||
}
|
||||
|
||||
// Prefer the server-cached thread parse — that's the richest structure
|
||||
// and the one the chat-bubble layout is built around. Skip when the user
|
||||
// has manually disabled bubble rendering.
|
||||
if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) {
|
||||
return _foldSignature(
|
||||
_renderTurnsAsBubbles(data.thread_turns, data),
|
||||
data && data.sender_signature || null,
|
||||
);
|
||||
}
|
||||
const b = data && data.boundaries;
|
||||
// Use cached boundaries when present AND we have plain-text body to slice
|
||||
if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) {
|
||||
// Pick the EARLIER of the two as the cut for "everything below this is
|
||||
// foldable", but render sig and quote with their own labels.
|
||||
let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1;
|
||||
let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1;
|
||||
// Clamp
|
||||
if (sig >= plain.length) sig = -1;
|
||||
if (quote >= plain.length) quote = -1;
|
||||
let head = plain;
|
||||
let sigSection = '';
|
||||
let quoteSection = '';
|
||||
if (sig >= 0 && quote >= 0) {
|
||||
const earlier = Math.min(sig, quote);
|
||||
head = plain.slice(0, earlier);
|
||||
if (sig < quote) {
|
||||
sigSection = plain.slice(sig, quote);
|
||||
quoteSection = plain.slice(quote);
|
||||
} else {
|
||||
quoteSection = plain.slice(quote, sig);
|
||||
sigSection = plain.slice(sig);
|
||||
}
|
||||
} else if (sig >= 0) {
|
||||
head = plain.slice(0, sig);
|
||||
sigSection = plain.slice(sig);
|
||||
} else {
|
||||
head = plain.slice(0, quote);
|
||||
quoteSection = plain.slice(quote);
|
||||
}
|
||||
const fmt = (s) => _escLinkify(s).replace(/\n/g, '<br>');
|
||||
let out = fmt(head);
|
||||
if (quoteSection) {
|
||||
out += '<details class="email-quote-fold">'
|
||||
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection))
|
||||
+ fmt(quoteSection) + '</details>';
|
||||
}
|
||||
if (sigSection) {
|
||||
const sigHtml = fmt(sigSection);
|
||||
if (_isBloatedSig(sigHtml)) {
|
||||
out += '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
|
||||
+ sigHtml + '</details>';
|
||||
} else {
|
||||
// Short closing — leave inline; folding would just add chrome.
|
||||
out += sigHtml;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Fallback: client-side parse (HTML or plaintext).
|
||||
const hintSig = (data && data.sender_signature) || null;
|
||||
const isHtml = !!data.body_html;
|
||||
let rendered;
|
||||
if (isHtml) {
|
||||
rendered = _sanitizeHtml(data.body_html);
|
||||
} else {
|
||||
const plainTurns = _renderPlaintextThread(data.body || '');
|
||||
if (plainTurns) return _foldSignature(plainTurns, hintSig);
|
||||
rendered = _escLinkify(data.body || '').replace(/\n/g, '<br>');
|
||||
}
|
||||
const threaded = _renderThreadStructure(rendered);
|
||||
if (threaded) return _prepareEmailInlineImages(_foldSignature(threaded, hintSig));
|
||||
return _prepareEmailInlineImages(_foldSignature(_foldQuotedReplies(rendered), hintSig));
|
||||
}
|
||||
|
||||
export function _safeRenderEmailBody(data) {
|
||||
try {
|
||||
return _prepareEmailInlineImages(_renderEmailBody(data));
|
||||
} catch (e) {
|
||||
console.error('email body render failed:', e);
|
||||
const plain = (typeof data?.body === 'string') ? data.body : '';
|
||||
if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
|
||||
if (data?.body_html) return _prepareEmailInlineImages(_sanitizeHtml(data.body_html));
|
||||
return '<span style="opacity:.65">No body</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function _prepareEmailInlineImages(html) {
|
||||
const raw = String(html || '');
|
||||
if (!/<img[\s>]/i.test(raw)) return raw;
|
||||
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html');
|
||||
const root = doc.body.firstElementChild;
|
||||
if (!root) return raw;
|
||||
root.querySelectorAll('img').forEach((img, idx) => {
|
||||
const src = (img.getAttribute('src') || '').trim();
|
||||
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
|
||||
const isHttp = /^https?:\/\//i.test(src);
|
||||
const isCid = /^cid:/i.test(src);
|
||||
const cid = isCid ? src.replace(/^cid:/i, '').replace(/^<|>$/g, '').trim() : '';
|
||||
const label = alt || (isCid ? 'Inline image' : 'Remote image');
|
||||
const ph = doc.createElement('span');
|
||||
ph.className = 'email-inline-image-placeholder';
|
||||
ph.setAttribute('role', 'group');
|
||||
ph.setAttribute('aria-label', label);
|
||||
if (src) ph.dataset.emailImgSrc = src;
|
||||
if (cid) ph.dataset.emailImgCid = cid;
|
||||
ph.innerHTML = `
|
||||
<span class="email-inline-image-skeleton">
|
||||
<span class="email-inline-image-icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="email-inline-image-info">
|
||||
<span class="email-inline-image-title">${_esc(label)}</span>
|
||||
<span class="email-inline-image-sub">${isHttp ? 'Remote image blocked' : isCid ? 'Inline image hidden' : 'Image unavailable'}</span>
|
||||
</span>
|
||||
<span class="email-inline-image-actions">
|
||||
${isHttp ? `<a class="email-inline-image-btn email-inline-image-download-btn" href="${_esc(src)}" target="_blank" rel="noopener noreferrer" download title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : ''}
|
||||
${isCid ? `<button type="button" class="email-inline-image-btn email-inline-image-download-btn" data-email-img-download="${idx}" title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>` : ''}
|
||||
</span>
|
||||
`;
|
||||
img.replaceWith(ph);
|
||||
});
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
export function _wireEmailInlineImages(reader) {
|
||||
if (!reader) return;
|
||||
const placeholders = Array.from(reader.querySelectorAll('.email-inline-image-placeholder'));
|
||||
const visiblePlaceholders = placeholders.filter(ph => (
|
||||
ph.isConnected &&
|
||||
(ph.dataset.emailImgSrc || ph.dataset.emailImgCid) &&
|
||||
getComputedStyle(ph).display !== 'none'
|
||||
));
|
||||
const autoLoadInlineImages = state._libViewInlineImages !== false;
|
||||
const manualPlaceholders = autoLoadInlineImages
|
||||
? visiblePlaceholders.filter(ph => !ph.dataset.emailImgCid)
|
||||
: visiblePlaceholders;
|
||||
const existingBulkBar = reader.querySelector('.email-inline-image-load-all');
|
||||
if (!manualPlaceholders.length) existingBulkBar?.remove();
|
||||
const loadPlaceholder = (ph) => {
|
||||
if (!ph || !ph.isConnected) return;
|
||||
const src = (ph.dataset.emailImgSrc || '').trim();
|
||||
const cid = (ph.dataset.emailImgCid || '').trim();
|
||||
const ctxRoot = reader.dataset?.emailUid
|
||||
? reader
|
||||
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
|
||||
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
|
||||
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
|
||||
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
|
||||
const inlineUrl = () => {
|
||||
if (!uid || !cid) return '';
|
||||
const params = new URLSearchParams({ cid, folder });
|
||||
if (account) params.set('account_id', account);
|
||||
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
|
||||
};
|
||||
const isRemoteImage = /^https?:\/\//i.test(src);
|
||||
const isCidImage = !!cid && !isRemoteImage;
|
||||
const loadSrc = isRemoteImage ? src : inlineUrl();
|
||||
if (!loadSrc || ph.classList.contains('is-loading')) return;
|
||||
ph.classList.remove('is-error');
|
||||
ph.classList.add('is-loading');
|
||||
const sub = ph.querySelector('.email-inline-image-sub');
|
||||
if (sub) sub.textContent = 'Loading image...';
|
||||
const actions = ph.querySelector('.email-inline-image-actions');
|
||||
if (actions) actions.style.display = 'none';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'email-inline-image-loaded';
|
||||
img.alt = ph.querySelector('.email-inline-image-title')?.textContent || 'Loaded email image';
|
||||
img.loading = 'eager';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
let settled = false;
|
||||
let objectUrl = '';
|
||||
let frame = null;
|
||||
let directFallbackUsed = false;
|
||||
const ensureFrame = () => {
|
||||
if (frame) return frame;
|
||||
frame = document.createElement('span');
|
||||
frame.className = 'email-inline-image-frame is-loading';
|
||||
frame.appendChild(img);
|
||||
if (ph.isConnected) ph.replaceWith(frame);
|
||||
return frame;
|
||||
};
|
||||
const showLoaded = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (frame) frame.classList.remove('is-loading');
|
||||
img.classList.add('is-visible');
|
||||
};
|
||||
const showError = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (frame?.isConnected) frame.replaceWith(ph);
|
||||
ph.classList.remove('is-loading');
|
||||
ph.classList.add('is-error');
|
||||
if (sub) sub.textContent = err?.message ? `Image failed: ${err.message}` : 'Image failed to load';
|
||||
if (actions) actions.style.display = '';
|
||||
if (objectUrl) {
|
||||
try { URL.revokeObjectURL(objectUrl); } catch {}
|
||||
}
|
||||
};
|
||||
const loadDirect = () => {
|
||||
directFallbackUsed = true;
|
||||
ensureFrame();
|
||||
img.src = loadSrc;
|
||||
if (img.complete && img.naturalWidth > 0) showLoaded();
|
||||
};
|
||||
img.addEventListener('load', showLoaded, { once: true });
|
||||
img.addEventListener('error', () => {
|
||||
if (!directFallbackUsed && objectUrl && isCidImage) {
|
||||
try { URL.revokeObjectURL(objectUrl); } catch {}
|
||||
objectUrl = '';
|
||||
loadDirect();
|
||||
return;
|
||||
}
|
||||
showError();
|
||||
}, { once: true });
|
||||
if (isCidImage || isRemoteImage) {
|
||||
loadDirect();
|
||||
setTimeout(() => {
|
||||
if (!settled && frame) {
|
||||
frame.classList.add('is-loading');
|
||||
}
|
||||
}, 12000);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
||||
fetch(loadSrc, { credentials: 'same-origin', signal: controller.signal })
|
||||
.then(async res => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
if (!blob || !blob.size) throw new Error('Empty image');
|
||||
const type = blob.type && blob.type.startsWith('image/') ? blob.type : 'image/png';
|
||||
objectUrl = URL.createObjectURL(blob.type === type ? blob : new Blob([blob], { type }));
|
||||
ensureFrame();
|
||||
img.src = objectUrl;
|
||||
if (img.complete && img.naturalWidth > 0) showLoaded();
|
||||
if (!settled && typeof img.decode === 'function') {
|
||||
img.decode().then(showLoaded).catch(() => {
|
||||
if (img.complete && img.naturalWidth === 0) showError();
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
clearTimeout(timeoutId);
|
||||
showError(err?.name === 'AbortError' ? new Error('request timed out') : err);
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (!settled && !frame) {
|
||||
ph.classList.remove('is-loading');
|
||||
if (sub) sub.textContent = 'Still loading...';
|
||||
if (actions) actions.style.display = '';
|
||||
}
|
||||
}, 12000);
|
||||
};
|
||||
if (manualPlaceholders.length && !reader.querySelector('.email-inline-image-load-all')) {
|
||||
const first = manualPlaceholders[0];
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'email-inline-image-load-all';
|
||||
const label = autoLoadInlineImages
|
||||
? `${manualPlaceholders.length} external image${manualPlaceholders.length === 1 ? '' : 's'} blocked`
|
||||
: `${manualPlaceholders.length} hidden inline image${manualPlaceholders.length === 1 ? '' : 's'}`;
|
||||
bar.innerHTML = `
|
||||
<span>${label}</span>
|
||||
<button type="button" data-email-img-dismiss-all>Dismiss</button>
|
||||
<button type="button" data-email-img-load-all>Load all</button>
|
||||
`;
|
||||
bar.querySelector('[data-email-img-load-all]')?.addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
bar.remove();
|
||||
manualPlaceholders.forEach(loadPlaceholder);
|
||||
});
|
||||
bar.querySelector('[data-email-img-dismiss-all]')?.addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
bar.remove();
|
||||
manualPlaceholders.forEach(ph => ph.remove());
|
||||
_disableInlineImages().catch(() => {});
|
||||
});
|
||||
first.parentNode?.insertBefore(bar, first);
|
||||
}
|
||||
placeholders.forEach(ph => {
|
||||
if (ph.dataset.wired !== '1') {
|
||||
ph.dataset.wired = '1';
|
||||
const src = (ph.dataset.emailImgSrc || '').trim();
|
||||
const cid = (ph.dataset.emailImgCid || '').trim();
|
||||
const ctxRoot = reader.dataset?.emailUid
|
||||
? reader
|
||||
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
|
||||
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
|
||||
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
|
||||
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
|
||||
const inlineUrl = () => {
|
||||
if (!uid || !cid) return '';
|
||||
const params = new URLSearchParams({ cid, folder });
|
||||
if (account) params.set('account_id', account);
|
||||
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
|
||||
};
|
||||
ph.querySelector('[data-email-img-download]')?.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const url = inlineUrl();
|
||||
if (!url) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'inline-image';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
}
|
||||
const cid = (ph.dataset.emailImgCid || '').trim();
|
||||
if (autoLoadInlineImages && cid && ph.isConnected && ph.dataset.emailImgAutoRequested !== '1') {
|
||||
ph.dataset.emailImgAutoRequested = '1';
|
||||
loadPlaceholder(ph);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Chat-bubble rendering for email threads ──
|
||||
// Each parsed turn renders as a chat bubble. Bubbles for the active
|
||||
// account's outgoing replies align right; everyone else aligns left.
|
||||
// Order is reversed so the oldest message sits at the top of the
|
||||
// conversation and the newest (the message currently being read) sits
|
||||
// at the bottom — matches the mental model people have from chat.
|
||||
|
||||
function _meEmailAddrs() {
|
||||
const set = new Set();
|
||||
for (const a of (state._libAccounts || [])) {
|
||||
if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim());
|
||||
if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim());
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor /
|
||||
// _initials live in ./emailLibrary/utils.js
|
||||
|
||||
function _renderTurnsAsBubbles(turns, data) {
|
||||
if (!Array.isArray(turns) || !turns.length) return '';
|
||||
const mineSet = _meEmailAddrs();
|
||||
const lvl0Email = String(data && data.from_address || '').toLowerCase().trim();
|
||||
const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email);
|
||||
const lvl0Author = (data && (data.from_name || data.from_address)) || '';
|
||||
const lvl0Date = _formatBubbleDate(data && data.date);
|
||||
|
||||
// Newest reply on top, older history below. Turns come ordered shallow→deep
|
||||
// (level 0 = current reply, deeper levels = older quoted material) so we
|
||||
// render in source order without reversing.
|
||||
const ordered = turns.slice();
|
||||
|
||||
// Gather per-turn sender identity + frequency for the no-self case below.
|
||||
const turnIdentity = ordered.map((t) => {
|
||||
if (t.level === 0) {
|
||||
return { email: lvl0Email, author: lvl0Author };
|
||||
}
|
||||
const p = _parseTurnMeta(t.meta || '');
|
||||
return { email: p.email, author: p.author };
|
||||
});
|
||||
const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email));
|
||||
// When the user isn't a participant in this thread (forwarded chains,
|
||||
// historical archives, etc.), assign the two most frequent senders to
|
||||
// opposite sides so the conversation still reads side-to-side. Third+
|
||||
// parties fall back to hash mod 2.
|
||||
const sideForKey = (() => {
|
||||
if (anyMine) return null;
|
||||
const freq = new Map();
|
||||
const firstSeen = new Map();
|
||||
turnIdentity.forEach((x, i) => {
|
||||
const key = (x.email || x.author || '').toLowerCase();
|
||||
if (!key) return;
|
||||
freq.set(key, (freq.get(key) || 0) + 1);
|
||||
if (!firstSeen.has(key)) firstSeen.set(key, i);
|
||||
});
|
||||
const sorted = [...freq.entries()]
|
||||
.sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0])));
|
||||
const leftKey = sorted[0] && sorted[0][0];
|
||||
const rightKey = sorted[1] && sorted[1][0];
|
||||
return (key) => {
|
||||
if (!key) return 'theirs';
|
||||
if (key === leftKey) return 'theirs';
|
||||
if (key === rightKey) return 'mine';
|
||||
// Stable hash for 3rd+ parties.
|
||||
let h = 0;
|
||||
for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0;
|
||||
return (h & 1) ? 'mine' : 'theirs';
|
||||
};
|
||||
})();
|
||||
|
||||
const rows = ordered.map((t, i) => {
|
||||
let isMine, author, date;
|
||||
if (t.level === 0) {
|
||||
isMine = lvl0Mine;
|
||||
author = lvl0Author || 'Me';
|
||||
date = lvl0Date;
|
||||
} else {
|
||||
const p = _parseTurnMeta(t.meta || '');
|
||||
isMine = !!p.email && mineSet.has(p.email);
|
||||
author = p.author || (t.meta || 'Earlier reply');
|
||||
date = p.date;
|
||||
}
|
||||
// No-self fallback: route by per-sender side mapping.
|
||||
if (sideForKey) {
|
||||
const id = turnIdentity[i];
|
||||
const key = (id.email || id.author || '').toLowerCase();
|
||||
isMine = sideForKey(key) === 'mine';
|
||||
}
|
||||
const side = isMine ? 'mine' : 'theirs';
|
||||
const initials = _initials(author);
|
||||
const color = _senderColor(author || (t.level === 0 ? lvl0Email : ''));
|
||||
const head =
|
||||
`<div class="email-bubble-head">`
|
||||
+ `<span class="email-bubble-author" style="color:${color}">${_esc(author)}</span>`
|
||||
+ (date ? `<span class="email-bubble-date">${_esc(date)}</span>` : '')
|
||||
+ `</div>`;
|
||||
const avatar = `<div class="email-bubble-avatar" aria-hidden="true" style="background:${color}">${_esc(initials)}</div>`;
|
||||
return (
|
||||
`<div class="email-bubble-row email-bubble-${side}" style="--bubble-accent:${color}">`
|
||||
+ (isMine ? '' : avatar)
|
||||
+ `<div class="email-bubble">`
|
||||
+ head
|
||||
+ `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
|
||||
+ `</div>`
|
||||
+ (isMine ? avatar : '')
|
||||
+ `</div>`
|
||||
);
|
||||
});
|
||||
return `<div class="email-bubbles">${rows.join('')}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render server-cached thread turns (list of {level, body_html, meta})
|
||||
* into the same nested-card structure the client-side parser produces.
|
||||
*/
|
||||
function _renderTurnsFromServer(turns) {
|
||||
if (!Array.isArray(turns) || !turns.length) return '';
|
||||
let out = '';
|
||||
const stack = []; // [{ level, html }]
|
||||
const wrap = (t) =>
|
||||
`<details class="email-thread-turn email-quote-fold">`
|
||||
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
|
||||
+ `<div class="email-thread-turn-body">${t.html}</div>`
|
||||
+ '</details>';
|
||||
|
||||
for (const t of turns) {
|
||||
if (t.level === 0) {
|
||||
while (stack.length) {
|
||||
const top = stack.pop();
|
||||
const w = wrap(top);
|
||||
if (stack.length) stack[stack.length - 1].html += w; else out += w;
|
||||
}
|
||||
out += _sanitizeHtml(t.body_html || '');
|
||||
} else {
|
||||
while (stack.length && stack[stack.length - 1].level > t.level) {
|
||||
const top = stack.pop();
|
||||
const w = wrap(top);
|
||||
if (stack.length) stack[stack.length - 1].html += w; else out += w;
|
||||
}
|
||||
if (!stack.length || stack[stack.length - 1].level < t.level) {
|
||||
stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') });
|
||||
} else {
|
||||
stack[stack.length - 1].html += _sanitizeHtml(t.body_html || '');
|
||||
if (t.meta && !stack[stack.length - 1].meta) {
|
||||
stack[stack.length - 1].meta = t.meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
const top = stack.pop();
|
||||
const w = wrap(top);
|
||||
if (stack.length) stack[stack.length - 1].html += w; else out += w;
|
||||
}
|
||||
// Mark the bottom-most fold for rounded corners.
|
||||
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
|
||||
if (lastIdx >= 0) {
|
||||
out = out.slice(0, lastIdx)
|
||||
+ out.slice(lastIdx).replace(
|
||||
'email-thread-turn email-quote-fold"',
|
||||
'email-thread-turn email-quote-fold last-fold"'
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email body's reply chain into a stack of turn-cards.
|
||||
* Each turn = { author, date, bodyHtml, nested[] } where the body is
|
||||
* everything UP TO the next quote boundary, and `nested` is the sub-thread
|
||||
* inside (recursively parsed). Returns null if the email has no quoted
|
||||
* thread to parse (single message, no folds needed).
|
||||
*/
|
||||
// ── Talon-inspired multilingual quote-detection patterns ──
|
||||
// Sources:
|
||||
// github.com/mailgun/talon (HTML/text quote detection)
|
||||
// github.com/crisp-oss/email-reply-parser (locale list)
|
||||
//
|
||||
// _TALON_* / _SIG_BLOAT_MIN_CHARS live in ./emailLibrary/utils.js
|
||||
// _SIG_ICON / _QUOTE_ICON live in ./emailLibrary/signatureFold.js
|
||||
|
||||
function _renderThreadStructure(html) {
|
||||
if (!html || typeof html !== 'string' || html.length > 200000) return null;
|
||||
let doc;
|
||||
try { doc = new DOMParser().parseFromString(`<div id="__t">${html}</div>`, 'text/html'); }
|
||||
catch { return null; }
|
||||
const root = doc.getElementById('__t');
|
||||
if (!root) return null;
|
||||
|
||||
// Find top-level blockquotes (not nested inside another blockquote).
|
||||
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
|
||||
!b.parentElement.closest('blockquote')
|
||||
);
|
||||
if (!tops.length) return null;
|
||||
|
||||
// Build the current-message body: everything in root up to the first
|
||||
// top-level blockquote, minus the "On <date>, <author> wrote:" attribution
|
||||
// line that introduces it.
|
||||
const head = doc.createElement('div');
|
||||
let cursor = root.firstChild;
|
||||
while (cursor && cursor !== tops[0]) {
|
||||
const next = cursor.nextSibling;
|
||||
head.appendChild(cursor);
|
||||
cursor = next;
|
||||
}
|
||||
// Strip trailing "On <date>, <name> wrote:" / Outlook-style attribution
|
||||
// from `head` since the same info will appear in the turn header.
|
||||
let attribution = _harvestAttribution(head);
|
||||
|
||||
// Recursively parse each top-level blockquote into a turn (and its nested chain).
|
||||
const turnsHtml = [];
|
||||
for (let i = 0; i < tops.length; i++) {
|
||||
const bq = tops[i];
|
||||
// The blockquote may have an Outlook-style "From: / Sent: / Subject:"
|
||||
// header inside as the first text. Extract that as the turn meta.
|
||||
const meta = _extractTurnMetaFromBlockquote(bq) || attribution || _extractQuoteMeta(bq.innerHTML);
|
||||
const innerHtml = bq.innerHTML;
|
||||
|
||||
// Heuristic: if a blockquote has no detectable attribution (no "From:",
|
||||
// no "On <date>... wrote:") AND its content matches signature-style
|
||||
// patterns (corporate disclaimer, "registered in", legal notices, just
|
||||
// a name + title), treat it as a Signature fold instead of an Earlier
|
||||
// Reply. This stops mail clients that wrap signatures in <blockquote>
|
||||
// from making the signature appear as a phantom prior email.
|
||||
if (!meta && _looksLikeSignature(innerHtml)) {
|
||||
turnsHtml.push(
|
||||
'<details class="email-sig-fold">'
|
||||
+ _foldSummary('Signature', _SIG_ICON)
|
||||
+ `<div class="email-sig-body">${innerHtml}</div>`
|
||||
+ '</details>'
|
||||
);
|
||||
attribution = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively render the inside of this blockquote (which may contain
|
||||
// its own nested blockquotes representing earlier replies).
|
||||
const nested = _renderThreadStructure(innerHtml);
|
||||
const bodyHtml = nested || innerHtml;
|
||||
const isLast = i === tops.length - 1;
|
||||
turnsHtml.push(
|
||||
`<details class="email-thread-turn email-quote-fold${isLast ? ' last-fold' : ''}">`
|
||||
+ _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
|
||||
+ `<div class="email-thread-turn-body">${bodyHtml}</div>`
|
||||
+ '</details>'
|
||||
);
|
||||
// Only the first turn uses the harvested attribution; deeper turns
|
||||
// get their own from inside the blockquote.
|
||||
attribution = null;
|
||||
}
|
||||
|
||||
return head.innerHTML + turnsHtml.join('');
|
||||
}
|
||||
|
||||
// Looks like a signature / corporate disclaimer rather than a quoted email.
|
||||
// Used to demote attribution-less blockquotes that some senders wrap their
|
||||
// sig+disclaimer in (Outlook, EY, big firms) from "Earlier reply" to a
|
||||
// proper Signature fold. Conservative — only fires when there's no quoted
|
||||
// reply markers AND it matches strong corporate-noise phrases.
|
||||
// _looksLikeSignature / _harvestAttribution / _extractTurnMetaFromBlockquote
|
||||
// live in ./emailLibrary/signatureFold.js
|
||||
|
||||
/**
|
||||
* Wrap any quoted reply chain in a collapsed <details> so deep email threads
|
||||
* don't dominate the reader. Detects:
|
||||
* - <blockquote> tags (Gmail / native quoted replies)
|
||||
* - Outlook-style "From: ... Sent: ... To: ... Subject: ..." headers
|
||||
* Each gets its own "Earlier thread" toggle.
|
||||
*/
|
||||
/**
|
||||
* Parse a plaintext email body into stacked turn-cards by walking
|
||||
* `> ` quote-prefix levels and Outlook-style "On X wrote:" / Original-Message
|
||||
* boundaries. Returns rendered HTML, or null when there's no quoted content
|
||||
* (caller falls back to flat rendering).
|
||||
*
|
||||
* Mirrors talon's `extract_from_plain` and email-reply-parser fragments:
|
||||
* 1. Lines starting with one or more `>` chars are quoted (level = count of >).
|
||||
* 2. Increasing the level opens a deeper turn (nested reply).
|
||||
* 3. `-----Original Message-----` and `On <date>, <name> wrote:` start a
|
||||
* new turn even without `>`.
|
||||
* 4. The leading non-quoted segment is the current message.
|
||||
*/
|
||||
function _renderPlaintextThread(text) {
|
||||
if (!text || typeof text !== 'string' || text.length > 200000) return null;
|
||||
const lines = text.split(/\r?\n/);
|
||||
const levels = lines.map(l => {
|
||||
const m = l.match(/^((?:>\s?)+)/);
|
||||
return m ? (m[1].match(/>/g) || []).length : 0;
|
||||
});
|
||||
const hasQuotes = levels.some(l => l > 0);
|
||||
const attribLineRe = new RegExp(`(?:^|\\n)\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'im');
|
||||
const hasAttrib = attribLineRe.test(text) || _TALON_ORIG_RE.test(text);
|
||||
if (!hasQuotes && !hasAttrib) return null;
|
||||
|
||||
const turns = [];
|
||||
let buf = [];
|
||||
let curLevel = 0;
|
||||
let pendingMeta = null;
|
||||
const flush = () => {
|
||||
if (!buf.length) return;
|
||||
const t = buf.join('\n').trimEnd();
|
||||
if (t || curLevel > 0) turns.push({ level: curLevel, text: t, meta: pendingMeta });
|
||||
buf = [];
|
||||
pendingMeta = null;
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lvl = levels[i];
|
||||
const raw = lines[i];
|
||||
const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw;
|
||||
const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim());
|
||||
const isAttribLine = lvl === 0
|
||||
&& (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
|
||||
|| _TALON_ORIG_RE.test('\n' + raw));
|
||||
if (isSeparatorLine || isAttribLine) {
|
||||
flush();
|
||||
pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
|
||||
curLevel = 1;
|
||||
continue;
|
||||
}
|
||||
if (lvl !== curLevel) {
|
||||
flush();
|
||||
curLevel = lvl;
|
||||
}
|
||||
buf.push(stripped);
|
||||
}
|
||||
flush();
|
||||
|
||||
if (!turns.length || (turns.length === 1 && turns[0].level === 0)) return null;
|
||||
|
||||
const fmt = s => _escLinkify(s).replace(/\n/g, '<br>');
|
||||
let out = '';
|
||||
const stack = [];
|
||||
const wrapTurn = (t) =>
|
||||
`<details class="email-thread-turn email-quote-fold">`
|
||||
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
|
||||
+ `<div class="email-thread-turn-body">${t.html}</div>`
|
||||
+ '</details>';
|
||||
|
||||
for (const t of turns) {
|
||||
if (t.level === 0) {
|
||||
while (stack.length) {
|
||||
const top = stack.pop();
|
||||
const wrapped = wrapTurn(top);
|
||||
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
|
||||
}
|
||||
out += fmt(t.text);
|
||||
} else {
|
||||
while (stack.length && stack[stack.length - 1].level > t.level) {
|
||||
const top = stack.pop();
|
||||
const wrapped = wrapTurn(top);
|
||||
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
|
||||
}
|
||||
if (!stack.length || stack[stack.length - 1].level < t.level) {
|
||||
stack.push({ level: t.level, meta: t.meta, html: fmt(t.text) });
|
||||
} else {
|
||||
stack[stack.length - 1].html += '<br>' + fmt(t.text);
|
||||
if (t.meta && !stack[stack.length - 1].meta) stack[stack.length - 1].meta = t.meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
const top = stack.pop();
|
||||
const wrapped = wrapTurn(top);
|
||||
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
|
||||
}
|
||||
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
|
||||
if (lastIdx >= 0) {
|
||||
out = out.slice(0, lastIdx)
|
||||
+ out.slice(lastIdx).replace(
|
||||
'email-thread-turn email-quote-fold"',
|
||||
'email-thread-turn email-quote-fold last-fold"'
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// _foldSummary / _extractQuoteMeta / _SIG_ICON / _QUOTE_ICON
|
||||
// live in ./emailLibrary/signatureFold.js
|
||||
|
||||
function _foldQuotedReplies(html) {
|
||||
if (!html || typeof html !== 'string') return html;
|
||||
if (html.length > 200000) return html;
|
||||
const before = html;
|
||||
// Use DOMParser for proper nested-blockquote handling. Regex against HTML
|
||||
// mishandles nesting and leaves orphan close tags that the browser
|
||||
// re-balances, producing two visually inconsistent fold styles.
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html');
|
||||
const root = doc.getElementById('__r');
|
||||
if (root) {
|
||||
// Only fold TOP-LEVEL blockquotes (children of the root that are not
|
||||
// already inside another blockquote). The inner blockquote chain stays
|
||||
// intact inside the fold and renders with the existing
|
||||
// .email-quote-fold blockquote styles, so everything matches.
|
||||
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
|
||||
!b.parentElement.closest('blockquote')
|
||||
);
|
||||
if (tops.length) {
|
||||
for (const bq of tops) {
|
||||
const det = doc.createElement('details');
|
||||
det.className = 'email-quote-fold';
|
||||
// Build the summary as raw HTML — easier than building DOM by hand.
|
||||
const summary = _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(bq.innerHTML));
|
||||
det.innerHTML = summary;
|
||||
bq.parentNode.insertBefore(det, bq);
|
||||
det.appendChild(bq); // move the original blockquote (and any nested ones) into the details
|
||||
}
|
||||
// Tag only the last fold so CSS can give it rounded bottom corners.
|
||||
const allFolds = root.querySelectorAll('.email-quote-fold');
|
||||
if (allFolds.length) allFolds[allFolds.length - 1].classList.add('last-fold');
|
||||
return root.innerHTML;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall through to the legacy regex path below if DOMParser fails
|
||||
}
|
||||
// If DOM-pass already wrapped something, we returned above. Otherwise no
|
||||
// blockquotes were found — try the Outlook-header heuristic.
|
||||
if (html !== before) return html;
|
||||
// Outlook-style quoted-reply header — multilingual. Fold from the first
|
||||
// "From: ... Sent: ... Subject: ..." block through end-of-body so all
|
||||
// prior thread levels collapse together.
|
||||
const FROM = '(?:From|Från|Von|De|De\\s|Da|От|Od|Van)';
|
||||
const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane)';
|
||||
const SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)';
|
||||
const outlookRe = new RegExp(
|
||||
`(<br\\s*/?>|</p>|</div>|<p[^>]*>|<div[^>]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
|
||||
'i'
|
||||
);
|
||||
const m = html.match(outlookRe);
|
||||
if (m) {
|
||||
const idx = html.lastIndexOf(m[0]);
|
||||
// Outlook fallback only ever produces ONE fold, so tag it as last.
|
||||
html = html.slice(0, idx) + m[1]
|
||||
+ '<details class="email-quote-fold last-fold">'
|
||||
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
|
||||
+ m[2] + '</details>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,856 @@
|
||||
// static/js/emailLibrary/menus.js
|
||||
//
|
||||
// Every action menu the email library opens: the reader's More menu, a card's
|
||||
// kebab menu, the bulk-selection Actions menu, and `_bulkAction`, which runs
|
||||
// the chosen bulk mutation.
|
||||
//
|
||||
// Item order is not local — it comes from `orderActionMenuItems` in
|
||||
// ../actionMenuOrder.js, so an action keeps the same position here as in the
|
||||
// document, task, session and memory menus. `tests/test_action_menu_order.py`
|
||||
// pins that.
|
||||
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { SELECT_MENU_ICON, actionMenuRank, orderActionMenuItems } from '../actionMenuOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from '../escMenuStack.js';
|
||||
import { topPortalZ } from '../toolWindowZOrder.js';
|
||||
import { showToast, styledConfirm } from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
_handleAiReplyButton,
|
||||
_showEmailTranslateSubmenu,
|
||||
_showLibRemindSubmenu,
|
||||
} from './aiReply.js';
|
||||
import {
|
||||
_acct,
|
||||
_animateEmailCardRemoval,
|
||||
_clearDoneResponseTagsLocal,
|
||||
_emailMutationQuery,
|
||||
_exportSelectedAttachments,
|
||||
_findSiblingEmailCard,
|
||||
_fitEmailDropdown,
|
||||
_libCacheWriteBack,
|
||||
_loadEmailsFresh,
|
||||
_renderGrid,
|
||||
_requireSuccessfulEmailMutation,
|
||||
_showEmailDeleteOverlay,
|
||||
_syncEmailReadState,
|
||||
_toggleCardPreview,
|
||||
} from './index.js';
|
||||
import { _openEmailAsTab } from './reader.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
export function _showReaderMoreMenu(em, card, reader, anchor, data) {
|
||||
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||
const existing = document.querySelector('.email-card-dropdown');
|
||||
if (existing && existing._anchor === anchor) {
|
||||
dismissOrRemove(existing);
|
||||
return;
|
||||
}
|
||||
// Otherwise close any other open dropdown (its own teardown clears its
|
||||
// anchor's active state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
dropdown._anchor = anchor;
|
||||
anchor.classList.add('reader-more-active');
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
|
||||
const _spamIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
||||
const _trashIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
|
||||
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
|
||||
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _translateIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
|
||||
|
||||
const closeAndRemove = async () => {
|
||||
// Pick the next neighbour BEFORE we re-render so we know which email to
|
||||
// jump to. Prefer the next card; fall back to the previous one if this
|
||||
// was the last card.
|
||||
const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1);
|
||||
const nextUid = sibling ? sibling.dataset.uid : null;
|
||||
await _animateEmailCardRemoval([em.uid]);
|
||||
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
|
||||
_renderGrid();
|
||||
_libCacheWriteBack();
|
||||
if (!nextUid) return;
|
||||
// After _renderGrid, the card nodes are fresh — re-resolve and expand.
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
|
||||
const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
|
||||
if (nextCard && nextEm) {
|
||||
_toggleCardPreview(nextCard, nextEm);
|
||||
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
const _bubblesIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const _contactIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>';
|
||||
// Three groups separated by dividers:
|
||||
// 1. Open / Mark Unread / Remind — the per-email view actions
|
||||
// 2. Save sender / Not Done / Archive — non-destructive state changes
|
||||
// 3. Move to Spam / Move to Trash / Delete — destructive
|
||||
const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({
|
||||
label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action',
|
||||
icon: button.querySelector('svg')?.outerHTML || '',
|
||||
button,
|
||||
action: (positionAnchor) => {
|
||||
if (button.dataset.act === 'ai-reply') {
|
||||
_handleAiReplyButton({
|
||||
currentTarget: button,
|
||||
stopPropagation() {},
|
||||
}, em, data, positionAnchor || button);
|
||||
} else {
|
||||
button.click();
|
||||
}
|
||||
},
|
||||
}));
|
||||
const actions = [
|
||||
...overflowActions,
|
||||
...(overflowActions.length ? [{ separator: true }] : []),
|
||||
{
|
||||
label: 'Open in new tab',
|
||||
icon: _newTabIcon,
|
||||
action: async () => {
|
||||
const folder = state._libFolder || 'INBOX';
|
||||
await _openEmailAsTab(em, folder);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Remind to reply',
|
||||
icon: _bellIcon,
|
||||
submenu: 'remind',
|
||||
},
|
||||
{
|
||||
label: 'Translate',
|
||||
icon: _translateIcon,
|
||||
submenu: 'translate',
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
|
||||
icon: _unreadIcon,
|
||||
action: async () => {
|
||||
const newRead = !em.is_read;
|
||||
_syncEmailReadState(em.uid, newRead);
|
||||
try {
|
||||
if (newRead) {
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Favorite (pin to top). Same bookmark glyph we use for the
|
||||
// sidebar-pin / favorites filter so the visual language stays
|
||||
// consistent. Toggling updates em.is_flagged and re-sorts via
|
||||
// _renderGrid (favorited rows are always pinned at the top).
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
// Roll back the optimistic flip if the server didn't take it.
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const newState = !em.is_answered;
|
||||
em.is_answered = newState;
|
||||
if (newState) {
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
_syncEmailReadState(em.uid, true);
|
||||
}
|
||||
try {
|
||||
if (newState) {
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (e) { console.error('Failed to toggle done:', e); }
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Move to Archive',
|
||||
icon: _archIcon,
|
||||
action: async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} catch (e) { console.error(e); }
|
||||
await closeAndRemove();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Save the sender to CardDAV contacts. Pulls name + address off the
|
||||
// list-item (em); falls back to splitting the local-part for a name.
|
||||
label: 'Save sender to contacts',
|
||||
icon: _contactIcon,
|
||||
action: async () => {
|
||||
const email = (em.from_address || em.from || '').trim();
|
||||
if (!email) {
|
||||
import('../ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('No sender address')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const name = (em.from_name || '').trim() || email.split('@')[0];
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/contacts/add`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
const d = await r.json();
|
||||
import('../ui.js?v=20260916largetoolscroll1').then(m => {
|
||||
if (!m.showToast) return;
|
||||
if (d.success && d.message === 'Already exists') m.showToast('Already in contacts');
|
||||
else if (d.success) m.showToast('Saved to contacts');
|
||||
else m.showError && m.showError('Failed to save contact');
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
import('../ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Move to Spam',
|
||||
icon: _spamIcon,
|
||||
action: async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/move/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&dest=Junk`, { method: 'POST' });
|
||||
} catch (e) { console.error(e); }
|
||||
await closeAndRemove();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Move to Trash',
|
||||
icon: _trashIcon,
|
||||
action: async () => {
|
||||
const busy = _showEmailDeleteOverlay(card);
|
||||
await busy?.ready;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
busy?.remove?.();
|
||||
showToast('Failed to delete email');
|
||||
return;
|
||||
}
|
||||
busy?.remove?.();
|
||||
await closeAndRemove();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Permanently',
|
||||
icon: _deleteForeverIcon,
|
||||
danger: true,
|
||||
action: async () => {
|
||||
const subject = em.subject || '(no subject)';
|
||||
const ok = await styledConfirm(
|
||||
`Permanently delete "${subject}"? This cannot be undone.`,
|
||||
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true }
|
||||
);
|
||||
if (!ok) return;
|
||||
const busy = _showEmailDeleteOverlay(card);
|
||||
await busy?.ready;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
busy?.remove?.();
|
||||
showToast('Failed to delete email');
|
||||
return;
|
||||
}
|
||||
busy?.remove?.();
|
||||
await closeAndRemove();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const a of actions) {
|
||||
if (a.separator) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'dropdown-divider';
|
||||
dropdown.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
|
||||
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (a.submenu === 'remind') {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
if (a.submenu === 'translate') {
|
||||
_showEmailTranslateSubmenu(reader, dropdown);
|
||||
return;
|
||||
}
|
||||
// A hidden overflowed button has a zero-sized client rect. Invoke the
|
||||
// AI chooser while the visible More item is still mounted, then close
|
||||
// the action menu after the chooser has captured its position.
|
||||
if (a.button?.dataset.act === 'ai-reply') {
|
||||
a.action(item);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
// Mobile-only Cancel item — explicit close for touch users. CSS hides it
|
||||
// on desktop where outside-click already dismisses cleanly.
|
||||
const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
const cancelItem = document.createElement('div');
|
||||
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
export function _showCardMenu(em, anchor) {
|
||||
const openMenu = document.querySelector('.email-card-dropdown:not(.email-bulk-menu)');
|
||||
if (openMenu && openMenu._emailMenuAnchor === anchor) {
|
||||
dismissOrRemove(openMenu);
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
dropdown._emailMenuAnchor = anchor;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
|
||||
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
|
||||
const _delIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
|
||||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _cardBellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
|
||||
const isSentFolder = /sent/i.test(state._libFolder);
|
||||
|
||||
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
const actions = [
|
||||
{ label: 'Open', icon: _replyIcon, action: async () => {
|
||||
// Just expand inline (same as tapping the row).
|
||||
const card = anchor.closest('.doclib-card');
|
||||
if (card && !card.classList.contains('doclib-card-expanded')) {
|
||||
await _toggleCardPreview(card, em);
|
||||
}
|
||||
}},
|
||||
{ label: 'Open in new tab', icon: _newTabIcon, action: async () => {
|
||||
// Open this email as its own in-app modal that registers a dock
|
||||
// chip — multiple emails can be opened simultaneously, each gets
|
||||
// its own chip in the minimized dock.
|
||||
const folder = state._libFolder || 'INBOX';
|
||||
await _openEmailAsTab(em, folder);
|
||||
}},
|
||||
{ label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
|
||||
];
|
||||
|
||||
if (!isSentFolder) {
|
||||
// Source of truth = the visible "active" class on the card's done
|
||||
// check, so the menu label and the actual toggle behaviour can't
|
||||
// disagree with what the user sees.
|
||||
const _cardForLabel = anchor.closest('.doclib-card');
|
||||
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
|
||||
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
|
||||
actions.push({
|
||||
label: _currentlyDone ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const card = anchor.closest('.doclib-card');
|
||||
const check = card ? card.querySelector('.email-card-done') : null;
|
||||
const wasActive = check ? check.classList.contains('active') : !!em.is_answered;
|
||||
const newState = !wasActive;
|
||||
em.is_answered = newState;
|
||||
if (newState) {
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
_syncEmailReadState(em.uid, true); // mark-done implies mark-read
|
||||
}
|
||||
try {
|
||||
if (newState) {
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (e) { console.error('Failed to toggle done:', e); }
|
||||
if (card) {
|
||||
if (check) check.classList.toggle('active', newState);
|
||||
if (newState) {
|
||||
_syncEmailReadState(em.uid, true);
|
||||
card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
action: async () => {
|
||||
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await _animateEmailCardRemoval([em.uid]);
|
||||
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
|
||||
_renderGrid();
|
||||
_libCacheWriteBack();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
|
||||
action: async () => {
|
||||
const next = !em.is_flagged;
|
||||
em.is_flagged = next;
|
||||
_renderGrid();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
em.is_flagged = !next;
|
||||
_renderGrid();
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.push({
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
action: async () => {
|
||||
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await _animateEmailCardRemoval([em.uid]);
|
||||
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
|
||||
_renderGrid();
|
||||
_libCacheWriteBack();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// "Select" — switch to multi-select mode with THIS email pre-selected so
|
||||
// the user can quickly fan-out to neighbours with the bulk bar.
|
||||
// Match the chat-sidebar Select icon — a thick bullet character reads
|
||||
// much heavier than a small SVG circle. Nudged up 2px so its visual
|
||||
// center lines up with the SVG icons above (which sit a bit higher).
|
||||
const _selectIcon = SELECT_MENU_ICON;
|
||||
actions.push({
|
||||
label: 'Select',
|
||||
icon: _selectIcon,
|
||||
action: () => {
|
||||
state._selectMode = true;
|
||||
state._selectedUids.add(em.uid);
|
||||
_updateBulkBar();
|
||||
_renderGrid();
|
||||
},
|
||||
});
|
||||
|
||||
actions.push(
|
||||
{ label: 'Delete', icon: _delIcon, danger: true, action: async () => {
|
||||
const subject = em.subject || '(no subject)';
|
||||
const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
|
||||
if (!ok) return;
|
||||
const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"]`);
|
||||
const busy = _showEmailDeleteOverlay(card);
|
||||
await busy?.ready;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (e) {
|
||||
busy?.remove?.();
|
||||
showToast(e?.message || 'Could not move email to Trash');
|
||||
return;
|
||||
}
|
||||
busy?.remove?.();
|
||||
await _animateEmailCardRemoval([em.uid]);
|
||||
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
|
||||
_renderGrid();
|
||||
_libCacheWriteBack();
|
||||
}},
|
||||
);
|
||||
|
||||
const orderedActions = orderActionMenuItems(actions);
|
||||
let addedActionDivider = false;
|
||||
for (const [index, a] of orderedActions.entries()) {
|
||||
if (!addedActionDivider && index > 0 && actionMenuRank(a) >= 700) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
dropdown.appendChild(divider);
|
||||
addedActionDivider = true;
|
||||
}
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
|
||||
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (a.submenu === 'remind') {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
// Mobile-only Cancel item — explicit close for touch users. CSS hides it
|
||||
// on desktop where outside-click already dismisses cleanly.
|
||||
const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
const cancelItem = document.createElement('div');
|
||||
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
||||
export function _showBulkActionsMenu(anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const items = [
|
||||
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
|
||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
|
||||
];
|
||||
for (const a of items) {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||
const _cancelIco2 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
const cancelIt = document.createElement('div');
|
||||
cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
||||
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
||||
cancelIt.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
||||
// documents bulk dropdown.
|
||||
state._selectMode = false;
|
||||
state._selectedUids.clear();
|
||||
_updateBulkBar();
|
||||
_renderGrid();
|
||||
});
|
||||
dropdown.appendChild(cancelIt);
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
export function _updateBulkBar() {
|
||||
const bar = document.getElementById('email-lib-bulk');
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
if (grid) grid.classList.toggle('email-selecting', !!state._selectMode);
|
||||
const selectBtn = document.getElementById('email-lib-select-btn');
|
||||
if (bar) bar.classList.toggle('hidden', !state._selectMode);
|
||||
if (selectBtn) {
|
||||
selectBtn.textContent = state._selectMode ? 'Cancel' : 'Select';
|
||||
selectBtn.classList.toggle('active', state._selectMode);
|
||||
}
|
||||
const count = document.getElementById('email-lib-selected-count');
|
||||
if (count) count.textContent = `${state._selectedUids.size} Selected`;
|
||||
const all = document.getElementById('email-lib-select-all');
|
||||
if (all) all.checked = state._libEmails.length > 0 && state._libEmails.every(e => state._selectedUids.has(e.uid));
|
||||
// When something's selected, brighten Actions to the same full --fg color as
|
||||
// the "N Selected" count (the button is a dimmer 60% --fg by default).
|
||||
const actions = document.getElementById('email-lib-bulk-actions');
|
||||
if (actions) actions.style.color = state._selectedUids.size > 0 ? 'var(--fg)' : '';
|
||||
const deleteBtn = document.getElementById('email-lib-bulk-delete');
|
||||
if (deleteBtn) deleteBtn.style.color = state._selectedUids.size > 0 ? 'var(--red)' : '';
|
||||
}
|
||||
|
||||
export async function _bulkAction(action) {
|
||||
const uids = Array.from(state._selectedUids);
|
||||
if (uids.length === 0) return;
|
||||
let failedReadSync = 0;
|
||||
const failedUids = new Set();
|
||||
if (action === 'delete') {
|
||||
const ok = await styledConfirm(
|
||||
`Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
|
||||
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true },
|
||||
);
|
||||
if (!ok) return;
|
||||
// Deletion is intentionally optimistic. The user should not have to keep
|
||||
// the mail window open while IMAP moves a batch; refresh and restore the
|
||||
// cards only if the provider rejects the background operation.
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
state._selectedUids.clear();
|
||||
state._selectMode = false;
|
||||
_updateBulkBar();
|
||||
_renderGrid();
|
||||
_libCacheWriteBack();
|
||||
showToast(`Moved ${uids.length} email${uids.length === 1 ? '' : 's'} to Trash`);
|
||||
fetch(`${API_BASE}/api/email/delete-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
|
||||
}).then(async (res) => {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || data?.success === false || (data?.failed_uids || []).length) {
|
||||
throw new Error(data?.error || `${data?.failed_uids?.length || 0} emails could not be deleted`);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Background bulk email delete failed:', error);
|
||||
showToast('Delete failed; restoring mailbox');
|
||||
_loadEmailsFresh({ force: true, useCache: false, refreshAfterCache: false }).catch(() => {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
|
||||
const actionsBtn = document.getElementById('email-lib-bulk-actions');
|
||||
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
|
||||
const selectAll = document.getElementById('email-lib-select-all');
|
||||
const countEl = document.getElementById('email-lib-selected-count');
|
||||
const originalDeleteHtml = deleteBtn?.innerHTML || '';
|
||||
const originalCountText = countEl?.textContent || '';
|
||||
let busySpinner = null;
|
||||
// Loading state for every bulk action, not just delete — large
|
||||
// selections (e.g. 90+ Dones) used to silently hammer the server
|
||||
// with sequential requests and the user got zero feedback. Now the
|
||||
// Actions button (or Delete button) shows a whirlpool + verb-ing
|
||||
// label, and the count surfaces progress.
|
||||
const verbing = {
|
||||
delete: 'Deleting',
|
||||
archive: 'Archiving',
|
||||
done: 'Marking done',
|
||||
read: 'Marking read',
|
||||
unread: 'Marking unread',
|
||||
}[action] || 'Updating';
|
||||
const targetBtn = action === 'delete' ? deleteBtn : actionsBtn;
|
||||
let originalTargetHtml = '';
|
||||
if (targetBtn) {
|
||||
originalTargetHtml = targetBtn.innerHTML;
|
||||
targetBtn.disabled = true;
|
||||
targetBtn.classList.add('email-bulk-loading');
|
||||
targetBtn.innerHTML = `<span class="email-bulk-loading-label">${verbing}</span>`;
|
||||
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const spEl = busySpinner.createElement();
|
||||
spEl.classList.add('email-bulk-whirlpool');
|
||||
targetBtn.appendChild(spEl);
|
||||
busySpinner.start();
|
||||
}
|
||||
if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true;
|
||||
if (action === 'delete' && actionsBtn) actionsBtn.disabled = true;
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `${verbing} ${uids.length}…`;
|
||||
const deleteOverlays = action === 'delete'
|
||||
? uids.map(uid => {
|
||||
const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(uid))}"]`);
|
||||
return _showEmailDeleteOverlay(card);
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
if (deleteOverlays.length) {
|
||||
await Promise.all(deleteOverlays.map(busy => busy.ready).filter(Boolean));
|
||||
}
|
||||
|
||||
// Single-uid worker.
|
||||
const handleOne = async (uid) => {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
} else if (action === 'delete') {
|
||||
// Bulk Delete matches the permanent-delete behavior used by the
|
||||
// library and albums. Moving a batch through provider Trash folders
|
||||
// is unreliable on several IMAP implementations.
|
||||
const res = await fetch(`${API_BASE}/api/email/delete-permanent/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
} else if (action === 'done') {
|
||||
// uid may come back from the Set as a string while em.uid is
|
||||
// numeric (or vice versa) — coerce both sides so the in-memory
|
||||
// state actually flips and the post-loop re-render shows the
|
||||
// done checkmark.
|
||||
const em = state._libEmails.find(e => String(e.uid) === String(uid));
|
||||
if (em) {
|
||||
em.is_answered = true;
|
||||
em.is_read = true;
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
}
|
||||
const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`);
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
|
||||
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
_syncEmailReadState(uid, action === 'read');
|
||||
}
|
||||
} catch (e) {
|
||||
if (action === 'read' || action === 'unread') failedReadSync += 1;
|
||||
failedUids.add(String(uid));
|
||||
console.error(`Failed to ${action} ${uid}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Run in parallel with a concurrency cap so 92 emails don't take
|
||||
// 30 seconds sequentially but we also don't open 92 simultaneous
|
||||
// connections.
|
||||
const CONCURRENCY = 6;
|
||||
const queue = uids.slice();
|
||||
let inFlight = 0;
|
||||
let nextSlot = 0;
|
||||
let finishedCount = 0;
|
||||
let bulkDeletedUids = null;
|
||||
if (action === 'delete') {
|
||||
const res = await fetch(`${API_BASE}/api/email/delete-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
for (const uid of data?.failed_uids || []) failedUids.add(String(uid));
|
||||
bulkDeletedUids = (data?.deleted_uids || []).map(String);
|
||||
if (countEl) countEl.textContent = `${verbing} ${bulkDeletedUids.length}/${uids.length}…`;
|
||||
} else await new Promise((resolve) => {
|
||||
const launch = () => {
|
||||
while (inFlight < CONCURRENCY && nextSlot < queue.length) {
|
||||
const uid = queue[nextSlot++];
|
||||
inFlight++;
|
||||
handleOne(uid).finally(() => {
|
||||
inFlight--;
|
||||
finishedCount++;
|
||||
if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`;
|
||||
if (nextSlot >= queue.length && inFlight === 0) resolve();
|
||||
else launch();
|
||||
});
|
||||
}
|
||||
if (queue.length === 0) resolve();
|
||||
};
|
||||
launch();
|
||||
});
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
const successfulUids = bulkDeletedUids || uids.filter(uid => !failedUids.has(String(uid)));
|
||||
if (action === 'delete') {
|
||||
deleteOverlays.forEach(busy => busy.remove?.());
|
||||
}
|
||||
await _animateEmailCardRemoval(successfulUids);
|
||||
const removed = new Set(successfulUids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
} else if (action === 'done' && state._libFilter === 'undone') {
|
||||
// The undone filter is a "show only not-done" view — after marking
|
||||
// selected emails done, they no longer match. Animate them out and
|
||||
// drop them from the local list so the view reflects the filter
|
||||
// instead of leaving freshly-done cards sitting there.
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
}
|
||||
} finally {
|
||||
deleteOverlays.forEach(busy => busy.remove?.());
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
// Restore whichever button we hijacked (delete vs actions).
|
||||
if (targetBtn) {
|
||||
targetBtn.disabled = false;
|
||||
targetBtn.classList.remove('email-bulk-loading');
|
||||
targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML;
|
||||
}
|
||||
if (deleteBtn && deleteBtn !== targetBtn) {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
|
||||
}
|
||||
if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false;
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (selectAll) selectAll.disabled = false;
|
||||
if (countEl) countEl.textContent = originalCountText;
|
||||
}
|
||||
state._selectedUids.clear();
|
||||
state._selectMode = false;
|
||||
_updateBulkBar();
|
||||
_renderGrid();
|
||||
if (failedReadSync > 0) {
|
||||
showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (failedUids.size > 0 && (action === 'delete' || action === 'archive')) {
|
||||
showToast(`${failedUids.size} email${failedUids.size === 1 ? '' : 's'} could not be ${action === 'delete' ? 'deleted' : 'archived'}`);
|
||||
}
|
||||
// Sync successful local mutations into the SWR cache so reopen doesn't
|
||||
// briefly show the pre-bulk state.
|
||||
_libCacheWriteBack();
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
// static/js/emailLibrary/reader.js
|
||||
//
|
||||
// The two ways an email opens outside the list: as a docked tab (a numbered
|
||||
// slot in the dock, `_openEmailAsTab`) and as a floating window
|
||||
// (`_openEmailWindow`). Plus the AI summary panel, which both of them and the
|
||||
// inline expanded card share.
|
||||
//
|
||||
// Reader slot numbers are deliberately sticky: once a reader is tab 2 it stays
|
||||
// tab 2 until it closes, even if tab 1 closes first. The slot map here is what
|
||||
// holds that.
|
||||
|
||||
import spinnerModule from '../spinner.js';
|
||||
import * as Modals from '../modalManager.js';
|
||||
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { state } from './state.js';
|
||||
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
|
||||
import { _aiReplyIcon, _handleAiReplyButton, _hasMultipleRecipients } from './aiReply.js';
|
||||
import {
|
||||
_buildAttsHtmlFor,
|
||||
_loadDeferredAttachmentsIntoReader,
|
||||
_wireEmailAttachmentWrap,
|
||||
} from './attachments.js';
|
||||
import {
|
||||
_renderEmailBody,
|
||||
_safeRenderEmailBody,
|
||||
_wireEmailInlineImages,
|
||||
} from './bodyRender.js';
|
||||
import {
|
||||
_acct,
|
||||
_emailReaderSkeletonHtml,
|
||||
_makeDraggable,
|
||||
_markEmailReaderActive,
|
||||
_maybeAutoTranslateEmail,
|
||||
_normalizeEmailStateFlags,
|
||||
_recipientChipHtml,
|
||||
_recipientMetaToggleHtml,
|
||||
_setSummaryCollapsedPref,
|
||||
_showEmailReaderLoadError,
|
||||
_snapEmailModalToLeftSidebar,
|
||||
_splitRecipientList,
|
||||
_stampReaderContext,
|
||||
_summaryCollapsedPref,
|
||||
_syncEmailDoneState,
|
||||
_syncEmailReadState,
|
||||
_toggleCardPreview,
|
||||
_wireMetaToggle,
|
||||
_wireReaderActionOverflow,
|
||||
_wireRecipientChips,
|
||||
} from './index.js';
|
||||
import { _showReaderMoreMenu } from './menus.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
// "Open in new tab" — the email opens in the library (expanded inline)
|
||||
// AND a separate floating "email viewer" overlay modal is created. The
|
||||
// overlay starts minimized as a chip in the dock; tapping the chip
|
||||
// brings the viewer up over the library. Multiple tabs = multiple
|
||||
// overlay modals + chips, each independent.
|
||||
const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7';
|
||||
let _emailTabSeq = 0;
|
||||
// Persistent slot numbers per reader modalId. Once a reader is "tab 2"
|
||||
// it stays "tab 2" until it's closed — even if tab 1 closes first, the
|
||||
// remaining reader doesn't renumber down to 1. New tabs claim the
|
||||
// lowest unused slot.
|
||||
const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...)
|
||||
function _allocReaderSlot(modalId) {
|
||||
if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId);
|
||||
const used = new Set(_emailReaderSlots.values());
|
||||
let n = 1;
|
||||
while (used.has(n)) n++;
|
||||
_emailReaderSlots.set(modalId, n);
|
||||
return n;
|
||||
}
|
||||
function _freeReaderSlot(modalId) {
|
||||
_emailReaderSlots.delete(modalId);
|
||||
}
|
||||
|
||||
// JS-driven gate: sets [data-email-tabs="N"] on <body> so CSS can show
|
||||
// the per-chip number badge only when 2+ tabs exist.
|
||||
function _syncEmailTabsCount() {
|
||||
const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]');
|
||||
document.body.dataset.emailTabs = String(tabs.length);
|
||||
}
|
||||
|
||||
// Recompute the email menu chip's tab-count whenever the dock contents
|
||||
// change. Counts "email-view-*" chips both inside #minimized-dock and
|
||||
// at body level (free-positioned chips on mobile). Result is written to
|
||||
// the email-lib-modal chip's data-tab-count attribute; CSS reads it via
|
||||
// attr() to render the badge.
|
||||
function _syncEmailTabBadge() {
|
||||
const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]');
|
||||
document.body.dataset.emailReaders = String(readers.length);
|
||||
// Stamp each chip with its persistent slot number. CSS reads
|
||||
// data-tab-num via attr() instead of using a counter so the number
|
||||
// stays stable when other tabs close.
|
||||
readers.forEach(chip => {
|
||||
const slot = _emailReaderSlots.get(chip.dataset.modalId);
|
||||
if (slot) chip.dataset.tabNum = String(slot);
|
||||
});
|
||||
}
|
||||
let _emailTabObserverWired = false;
|
||||
let _badgeSyncScheduled = false;
|
||||
function _ensureEmailTabObserver() {
|
||||
if (_emailTabObserverWired) return;
|
||||
_emailTabObserverWired = true;
|
||||
// Debounce so a burst of mutations (e.g. _renderDock rebuilding the
|
||||
// whole dock in one pass) collapses to a single sync per animation
|
||||
// frame. Without this the chip badge could flicker as the observer
|
||||
// fires repeatedly during dock rerenders.
|
||||
const handler = () => {
|
||||
if (_badgeSyncScheduled) return;
|
||||
_badgeSyncScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
_badgeSyncScheduled = false;
|
||||
_syncEmailTabBadge();
|
||||
});
|
||||
};
|
||||
const tryWire = () => {
|
||||
const dock = document.getElementById('minimized-dock');
|
||||
if (!dock) { setTimeout(tryWire, 200); return; }
|
||||
// Only watch what we care about: chip add/remove in the dock.
|
||||
const obs = new MutationObserver(handler);
|
||||
obs.observe(dock, { childList: true });
|
||||
// Watch the library grid so toggling a card expanded/collapsed
|
||||
// updates the lib chip's "has-expanded" badge in real time.
|
||||
const wireGridObs = () => {
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
if (!grid) { setTimeout(wireGridObs, 500); return; }
|
||||
const gridObs = new MutationObserver(handler);
|
||||
gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] });
|
||||
};
|
||||
wireGridObs();
|
||||
handler();
|
||||
};
|
||||
tryWire();
|
||||
}
|
||||
// Hybrid model:
|
||||
// - email-lib-modal (the inbox library) is unique. Its chip just
|
||||
// restores it.
|
||||
// - Each "Open in new tab" creates a separate per-email reader modal
|
||||
// (id "email-reader-{uid}-{seq}") with the SAME structure & classes
|
||||
// as the library's inline reader, so they look identical. Each
|
||||
// reader registers its own dock chip with a number badge.
|
||||
export async function _openEmailAsTab(em, folder) {
|
||||
const useFolder = folder || state._libFolder || 'INBOX';
|
||||
_emailTabSeq += 1;
|
||||
const modalId = `email-reader-${em.uid}-${_emailTabSeq}`;
|
||||
_allocReaderSlot(modalId);
|
||||
|
||||
// Build the modal shell. Uses the same doclib-modal-content sizing
|
||||
// as the email library so it feels like a sibling window. The reader
|
||||
// body inside uses the exact same email-card-reader / email-reader-*
|
||||
// classes the inline reader uses → identical styling.
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal email-reader-tab-modal';
|
||||
modal.id = modalId;
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
|
||||
<div class="modal-header">
|
||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
|
||||
</h4>
|
||||
<button class="minimize-btn" type="button" title="Minimize">_</button>
|
||||
<button class="close-btn" type="button" title="Close">✖</button>
|
||||
</div>
|
||||
<div class="modal-body email-reader-tab-body" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;padding:0;">
|
||||
<div class="email-card-reader email-card-expanded" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<div class="email-reader-tab-loading" style="padding:24px;display:flex;justify-content:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
// Inherit display from .modal (flex-center). z-index above the library
|
||||
// (which uses default .modal z-index 250) so the new tab sits on top.
|
||||
modal.style.zIndex = '270';
|
||||
// Opened last → email windows in front of any open doc (alternation flag).
|
||||
document.body.classList.add('email-front');
|
||||
|
||||
Modals.register(modalId, {
|
||||
label: 'Email',
|
||||
icon: _EMAIL_ICON_PATH,
|
||||
closeFn: () => {
|
||||
modal.remove();
|
||||
_freeReaderSlot(modalId);
|
||||
Promise.resolve().then(_syncEmailTabBadge);
|
||||
},
|
||||
restoreFn: () => {
|
||||
// Reopened last → bring the email windows in front of any open doc.
|
||||
document.body.classList.add('email-front');
|
||||
// Mobile: only one email window visible at a time. Tapping this
|
||||
// chip chips down the library + any other reader, so the user
|
||||
// toggles between them via the dock instead of stacking.
|
||||
if (window.innerWidth <= 768) {
|
||||
try {
|
||||
if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
|
||||
Modals.minimize('email-lib-modal');
|
||||
}
|
||||
} catch {}
|
||||
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
|
||||
if (other.id === modalId) return;
|
||||
try {
|
||||
if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
|
||||
Modals.minimize(other.id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
// Wire the `_` minimize button via modalManager (it sees our .minimize-btn
|
||||
// already exists and just binds the click handler).
|
||||
try { Modals.injectMinimizeButton(modal, modalId); } catch {}
|
||||
// X button fully closes the tab (tears down and unregisters).
|
||||
modal.querySelector('.close-btn')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
Modals.close(modalId);
|
||||
});
|
||||
|
||||
// Wire dragging on the header (desktop only). Matches the global pattern
|
||||
// in app.js initUIVisibility, but that runs once at boot and doesn't see
|
||||
// dynamically-created modals — so we replicate it here.
|
||||
const content = modal.querySelector('.modal-content');
|
||||
const mh = modal.querySelector('.modal-header');
|
||||
if (mh && content) {
|
||||
let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false;
|
||||
const startDrag = (clientX, clientY) => {
|
||||
dragging = true;
|
||||
const rect = content.getBoundingClientRect();
|
||||
dragX = clientX; dragY = clientY;
|
||||
startLeft = rect.left; startTop = rect.top;
|
||||
content.style.position = 'fixed';
|
||||
content.style.left = startLeft + 'px';
|
||||
content.style.top = startTop + 'px';
|
||||
content.style.margin = '0';
|
||||
};
|
||||
const onDrag = (e) => {
|
||||
if (!dragging) return;
|
||||
content.style.left = (startLeft + e.clientX - dragX) + 'px';
|
||||
content.style.top = (startTop + e.clientY - dragY) + 'px';
|
||||
};
|
||||
const stopDrag = () => {
|
||||
dragging = false;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
};
|
||||
mh.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return;
|
||||
e.preventDefault();
|
||||
startDrag(e.clientX, e.clientY);
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
});
|
||||
}
|
||||
|
||||
// Open the new tab in front, on top of the email library. The user
|
||||
// can tap `_` to tab it down to a chip when they're done reading.
|
||||
//
|
||||
// Mobile: bottom-sheet windows fill the viewport, so stacking multiple
|
||||
// readers on top of each other is confusing — only one window can be
|
||||
// meaningfully visible at a time. So when the new tab opens, chip down
|
||||
// the library AND any other email-reader-* tab that's currently up.
|
||||
// The user gets a stack of mini chips to toggle between them.
|
||||
if (window.innerWidth <= 768) {
|
||||
try {
|
||||
if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
|
||||
Modals.minimize('email-lib-modal');
|
||||
}
|
||||
} catch {}
|
||||
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
|
||||
if (other.id === modalId) return;
|
||||
try {
|
||||
if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
|
||||
Modals.minimize(other.id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
_ensureEmailTabObserver();
|
||||
_syncEmailTabBadge();
|
||||
|
||||
// Fetch + render the email body using the exact same template as
|
||||
// _toggleCardPreview so the visuals match perfectly.
|
||||
const reader = modal.querySelector('.email-card-reader');
|
||||
const showFailedTab = (message) => {
|
||||
try { showToast(message || 'Failed to load email'); } catch (_) {}
|
||||
_showEmailReaderLoadError(reader, message, () => {
|
||||
try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} }
|
||||
setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0);
|
||||
});
|
||||
};
|
||||
_markEmailReaderActive(reader);
|
||||
const loading = modal.querySelector('.email-reader-tab-loading');
|
||||
if (loading) loading.remove();
|
||||
if (reader) {
|
||||
reader.classList.add('email-card-reader-loading');
|
||||
reader.innerHTML = _emailReaderSkeletonHtml();
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
let data = await res.json();
|
||||
if (data.error) {
|
||||
showFailedTab(`Failed to load email: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
data = _normalizeEmailStateFlags({ ...em, ...data });
|
||||
Object.assign(em, data);
|
||||
_syncEmailDoneState(em.uid, data.is_answered);
|
||||
_syncEmailReadState(em.uid, true);
|
||||
_stampReaderContext(reader, data, useFolder, state._libAccountId);
|
||||
const buildChips = (str) => {
|
||||
if (!str) return '';
|
||||
return _splitRecipientList(str).map(a => {
|
||||
const name = _extractName(a);
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
let attsHtml = '';
|
||||
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
|
||||
reader.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
<div class="email-reader-meta">
|
||||
<div class="email-reader-meta-row email-reader-meta-from">
|
||||
<strong>From:</strong>
|
||||
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
|
||||
</div>
|
||||
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
|
||||
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildChips(data.to)}</span></div>` : ''}
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
reader.classList.remove('email-card-reader-loading');
|
||||
_wireRecipientChips(reader);
|
||||
_wireEmailAttachmentWrap(reader, useFolder);
|
||||
_wireEmailInlineImages(reader);
|
||||
_loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments);
|
||||
_maybeAutoTranslateEmail(reader);
|
||||
reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
|
||||
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
|
||||
});
|
||||
reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
|
||||
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
|
||||
});
|
||||
reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
|
||||
reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
|
||||
});
|
||||
reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {}
|
||||
});
|
||||
_wireMetaToggle(reader);
|
||||
_wireReaderActionOverflow(reader);
|
||||
reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {}
|
||||
});
|
||||
} catch (err) {
|
||||
showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// "Open in new window" — spawns a floating draggable modal that shows just
|
||||
// the email content. Multiple windows can stack; each has its own DOM id
|
||||
// and close button. Uses `_makeDraggable` so dragging the header pans the
|
||||
// window around. Renders the body via _renderEmailBody for parity with the
|
||||
// expanded reader.
|
||||
let _emailWindowSeq = 0;
|
||||
export async function _openEmailWindow(em, folder) {
|
||||
const useFolder = folder || state._libFolder || 'INBOX';
|
||||
_emailWindowSeq += 1;
|
||||
const winId = `email-window-${em.uid}-${_emailWindowSeq}`;
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal email-window-modal';
|
||||
modal.id = winId;
|
||||
modal.style.cssText = 'pointer-events:none;background:transparent;';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
<span class="email-window-subject" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(em.subject || '(no subject)')}</span>
|
||||
</h4>
|
||||
<button class="close-btn" type="button" title="Close">✖</button>
|
||||
</div>
|
||||
<div class="modal-body email-window-body" style="overflow:auto;padding:14px 16px;flex:1;min-height:0;">
|
||||
<div class="email-window-loading" style="display:flex;justify-content:center;padding:24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
const content = modal.querySelector('.modal-content');
|
||||
// Position offset from screen center so successive windows cascade.
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
if (isMobile) {
|
||||
content.style.position = 'fixed';
|
||||
content.style.pointerEvents = 'auto';
|
||||
content.style.left = '0';
|
||||
content.style.right = '0';
|
||||
content.style.bottom = '0';
|
||||
content.style.top = 'auto';
|
||||
} else {
|
||||
content.style.position = 'fixed';
|
||||
content.style.pointerEvents = 'auto';
|
||||
requestAnimationFrame(() => {
|
||||
const w = content.offsetWidth, h = content.offsetHeight;
|
||||
const off = (_emailWindowSeq % 6) * 28;
|
||||
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
|
||||
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
|
||||
});
|
||||
}
|
||||
modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove());
|
||||
try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {}
|
||||
|
||||
// Load + render
|
||||
const bodyEl = modal.querySelector('.email-window-body');
|
||||
const loading = modal.querySelector('.email-window-loading');
|
||||
try {
|
||||
if (loading) loading.remove();
|
||||
if (bodyEl) {
|
||||
bodyEl.classList.add('email-card-reader', 'email-card-reader-loading');
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.innerHTML = _emailReaderSkeletonHtml();
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
|
||||
let data = await res.json();
|
||||
if (data.error) {
|
||||
bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">${_esc(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
data = _normalizeEmailStateFlags({ ...em, ...data });
|
||||
Object.assign(em, data);
|
||||
_syncEmailDoneState(em.uid, data.is_answered);
|
||||
_syncEmailReadState(em.uid, true);
|
||||
const subjEl = modal.querySelector('.email-window-subject');
|
||||
if (subjEl && data.subject) subjEl.textContent = data.subject;
|
||||
// Build recipient chips the same way the inline reader does so the
|
||||
// standalone viewer looks/feels exactly like a real email view.
|
||||
const _chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
const list = _splitRecipientList(addrs);
|
||||
return list.map(a => {
|
||||
const name = _extractName(a);
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
let attsHtml = '';
|
||||
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
|
||||
// Repurpose bodyEl as a full email-card-reader so the inline reader's
|
||||
// CSS applies (sized header, action buttons in two rows, etc.).
|
||||
bodyEl.classList.add('email-card-reader');
|
||||
bodyEl.classList.remove('email-card-reader-loading');
|
||||
_stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId);
|
||||
_markEmailReaderActive(bodyEl);
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
<div class="email-reader-meta">
|
||||
<div class="email-reader-meta-row email-reader-meta-from">
|
||||
<strong>From:</strong>
|
||||
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
|
||||
</div>
|
||||
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
|
||||
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${_chipsFor(data.to)}</span></div>` : ''}
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
|
||||
</div>` : ''}
|
||||
<div class="email-reader-actions-inline">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
|
||||
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
|
||||
<div class="email-reader-more-wrap" style="position:relative">
|
||||
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(bodyEl);
|
||||
_wireRecipientChips(bodyEl);
|
||||
// Wire all the same action handlers the inline reader has.
|
||||
_wireEmailAttachmentWrap(bodyEl, useFolder);
|
||||
_wireEmailInlineImages(bodyEl);
|
||||
_loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments);
|
||||
_maybeAutoTranslateEmail(bodyEl);
|
||||
bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
|
||||
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
|
||||
});
|
||||
bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
|
||||
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
|
||||
});
|
||||
bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
|
||||
bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
|
||||
});
|
||||
bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {}
|
||||
});
|
||||
_wireMetaToggle(bodyEl);
|
||||
_wireReaderActionOverflow(bodyEl);
|
||||
bodyEl.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
// Use a synthetic "card" — the more-menu only needs the anchor
|
||||
// element and the email data. The card param is mostly used to find
|
||||
// the next sibling; the standalone window has none so we just pass
|
||||
// bodyEl as a stand-in.
|
||||
try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget, data); } catch {}
|
||||
});
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">Failed to load: ${_esc(String(err))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function _summarizeEmail(reader, data, btn) {
|
||||
const body = reader.querySelector('.email-reader-body');
|
||||
if (!body) return;
|
||||
|
||||
// If a summary panel already exists, toggle: hide/show
|
||||
const existing = body.querySelector('.email-summary-panel');
|
||||
if (existing) {
|
||||
if (existing.style.display === 'none') {
|
||||
existing.style.display = '';
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
btn.querySelector('.btn-label').textContent = 'Summary';
|
||||
}
|
||||
} else {
|
||||
existing.style.display = 'none';
|
||||
if (btn) {
|
||||
btn.classList.remove('active');
|
||||
btn.querySelector('.btn-label').textContent = 'Summary';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No panel yet. If the email has no cached AI summary, show a placeholder
|
||||
// "not generated — create now?" prompt instead of firing the LLM immediately.
|
||||
// This avoids accidental LLM spend and makes the state explicit to the user.
|
||||
if (!data.cached_summary) {
|
||||
const prompt = document.createElement('div');
|
||||
prompt.className = 'email-summary-panel';
|
||||
prompt.innerHTML = `
|
||||
<div class="email-summary-header">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
<span>Summary</span>
|
||||
</div>
|
||||
<div class="email-summary-content" style="white-space:normal;display:flex;align-items:center;flex-wrap:wrap;gap:6px;"><span style="opacity:0.65">No AI summary generated.</span><button class="memory-toolbar-btn" data-act="summary-generate" style="font-size:10px;margin-left:auto;">Generate now</button></div>`;
|
||||
body.insertBefore(prompt, body.firstChild);
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
const label = btn.querySelector('.btn-label');
|
||||
if (label) label.textContent = 'Summary';
|
||||
}
|
||||
// No Cancel button — toggling the Summary button again hides this panel
|
||||
// (handled by the existing-panel branch above), so it'd be redundant.
|
||||
prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
prompt.remove();
|
||||
await _generateSummary(reader, data, btn);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cached summary exists — show it immediately.
|
||||
await _generateSummary(reader, data, btn);
|
||||
}
|
||||
|
||||
async function _generateSummary(reader, data, btn) {
|
||||
const body = reader.querySelector('.email-reader-body');
|
||||
if (!body) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'email-summary-panel';
|
||||
panel.innerHTML =
|
||||
'<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
|
||||
+ '<span>Summary</span>'
|
||||
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</div>'
|
||||
+ '<div class="email-summary-content"></div>';
|
||||
if (_summaryCollapsedPref()) panel.classList.add('collapsed');
|
||||
body.insertBefore(panel, body.firstChild);
|
||||
const _genToggle = panel.querySelector('.email-summary-toggle');
|
||||
if (_genToggle) {
|
||||
const _genFlip = () => {
|
||||
panel.classList.toggle('collapsed');
|
||||
_setSummaryCollapsedPref(panel.classList.contains('collapsed'));
|
||||
};
|
||||
_genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); });
|
||||
_genToggle.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); }
|
||||
});
|
||||
}
|
||||
|
||||
const sp = spinnerModule.createWhirlpool(18);
|
||||
const content = panel.querySelector('.email-summary-content');
|
||||
content.appendChild(sp.element);
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/summarize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
body: data.body,
|
||||
subject: data.subject,
|
||||
from: `${data.from_name} <${data.from_address}>`,
|
||||
// Send identifiers so the backend can fetch the raw message and
|
||||
// pull attachment text for the summary (PDFs, invoices, etc.).
|
||||
uid: data.uid || '',
|
||||
folder: state._libFolder || 'INBOX',
|
||||
message_id: data.message_id || '',
|
||||
account_id: data.account_id || '',
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
sp.destroy();
|
||||
content.innerHTML = '';
|
||||
if (result.success && result.summary) {
|
||||
content.textContent = result.summary;
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
const label = btn.querySelector('.btn-label');
|
||||
if (label) label.textContent = 'Summary';
|
||||
}
|
||||
} else {
|
||||
_renderEmailSummaryError(content, result);
|
||||
}
|
||||
} catch (e) {
|
||||
sp.destroy();
|
||||
_renderEmailSummaryError(content, null);
|
||||
if (uiModule) uiModule.showError?.('Failed to summarize');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function _emailBodyTextForTranslate(reader) {
|
||||
const body = reader?.querySelector?.('.email-reader-body');
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true);
|
||||
clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove());
|
||||
return (clone.innerText || clone.textContent || '').trim();
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
// static/js/emailLibrary/settingsPage.js
|
||||
//
|
||||
// The Email Settings page: the in-panel settings view, its form markup and
|
||||
// controls, the away/auto-reply configuration, and the display preferences
|
||||
// (inline images, tag visibility, compact rows).
|
||||
//
|
||||
// Two things here reach outside settings, which is why they live together.
|
||||
// Turning away-replies on writes a matching calendar event, so this module owns
|
||||
// the `odysseus.email.autoReplyCalendarEvent.*` keys and the create/update/
|
||||
// delete sync against /api/calendar. And the inline-image preference is read by
|
||||
// ./bodyRender.js while rendering a message, not only by the settings form.
|
||||
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { emailApiUrl } from '../emailShared.js';
|
||||
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { state } from './state.js';
|
||||
import { _esc } from './utils.js';
|
||||
import { _wireEmailInlineImages } from './bodyRender.js';
|
||||
import {
|
||||
_loadAccounts,
|
||||
_loadEmails,
|
||||
_loadFolders,
|
||||
_loadedEmailsHaveVisibleTags,
|
||||
_notifyNoLoadedEmailTags,
|
||||
_openTasksForEmailTags,
|
||||
_publishActiveAccount,
|
||||
_refreshUnreadBadge,
|
||||
_renderAccountsStrip,
|
||||
_renderGrid,
|
||||
_resetEmailListForFreshLoad,
|
||||
} from './index.js';
|
||||
import { _openUnsubscribeReviewModal } from './unsubscribe.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function _emailInlineImagesStorageKey(accountId = state._libAccountId) {
|
||||
return `odysseus.email.viewInlineImages.${String(accountId || 'default')}`;
|
||||
}
|
||||
|
||||
export function _readEmailInlineImagesPreference(accountId = state._libAccountId) {
|
||||
try {
|
||||
const stored = localStorage.getItem(_emailInlineImagesStorageKey(accountId));
|
||||
return stored === null ? true : stored !== '0';
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function _writeEmailInlineImagesPreference(enabled, accountId = state._libAccountId) {
|
||||
try {
|
||||
localStorage.setItem(_emailInlineImagesStorageKey(accountId), enabled ? '1' : '0');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export async function _disableInlineImages(accountId = state._libAccountId) {
|
||||
state._libViewInlineImages = false;
|
||||
_writeEmailInlineImagesPreference(false, accountId);
|
||||
document.querySelectorAll('#email-settings-view-inline-images').forEach(toggle => {
|
||||
toggle.checked = true;
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
try {
|
||||
const res = await fetch(emailApiUrl('/api/email/config', {
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email_view_inline_images: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to disable inline images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export const _EMAIL_SETTINGS_ICON = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>`;
|
||||
const _EMAIL_SAVE_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>';
|
||||
const _EMAIL_SAVED_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
|
||||
function _setEmailSaveIcon(button, saved) {
|
||||
if (!button) return;
|
||||
const icon = button.querySelector('svg');
|
||||
if (icon) icon.outerHTML = saved ? _EMAIL_SAVED_ICON : _EMAIL_SAVE_ICON;
|
||||
button.classList.toggle('is-saved', !!saved);
|
||||
}
|
||||
const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}';
|
||||
const _DEFAULT_AUTO_REPLY_MESSAGE = "Thanks for your email. I'm away and may be slower to reply.";
|
||||
|
||||
function _normalizeAutoReplyConfig(cfg = {}) {
|
||||
const normalized = Object.prototype.hasOwnProperty.call(cfg, 'enabled');
|
||||
return {
|
||||
enabled: normalized ? !!cfg.enabled : !!cfg.email_auto_reply,
|
||||
start: String(normalized ? (cfg.start || '') : (cfg.email_auto_reply_start || '')),
|
||||
end: String(normalized ? (cfg.end || '') : (cfg.email_auto_reply_end || '')),
|
||||
subject: String(normalized ? (cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT) : (cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT)),
|
||||
message: String(normalized ? (cfg.message || '') : (cfg.email_auto_reply_message || '')),
|
||||
cooldown: String(normalized ? (cfg.cooldown || 'period') : (cfg.email_auto_reply_cooldown || 'period')),
|
||||
scope: String(normalized ? (cfg.scope || 'all') : (cfg.email_auto_reply_scope || 'all')),
|
||||
accountId: String(normalized ? (cfg.accountId || '') : (cfg.email_auto_reply_account_id || '')),
|
||||
excludeAutomated: normalized ? cfg.excludeAutomated !== false : cfg.email_auto_reply_exclude_automated !== false,
|
||||
pauseNotifications: normalized ? !!cfg.pauseNotifications : !!cfg.email_auto_reply_pause_notifications,
|
||||
viewInlineImages: normalized ? cfg.viewInlineImages !== false : cfg.email_view_inline_images !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function _autoReplyDateValue(value) {
|
||||
const s = String(value || '').trim();
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function _todayDateInputValue() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
}
|
||||
|
||||
const _AUTO_REPLY_CALENDAR_KEY_PREFIX = 'odysseus.email.autoReplyCalendarEvent.';
|
||||
const _autoReplyCalendarSyncs = new Map();
|
||||
|
||||
function _autoReplyCalendarKey(accountId) {
|
||||
return `${_AUTO_REPLY_CALENDAR_KEY_PREFIX}${String(accountId || '').trim()}`;
|
||||
}
|
||||
|
||||
function _readAutoReplyCalendarUid(accountId) {
|
||||
if (!accountId) return '';
|
||||
try { return String(localStorage.getItem(_autoReplyCalendarKey(accountId)) || ''); } catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function _writeAutoReplyCalendarUid(accountId, uid) {
|
||||
if (!accountId) return;
|
||||
try {
|
||||
if (uid) localStorage.setItem(_autoReplyCalendarKey(accountId), uid);
|
||||
else localStorage.removeItem(_autoReplyCalendarKey(accountId));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _addLocalCalendarDays(value, days) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return '';
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function _autoReplyCalendarPayload(cfg, accountId = state._libAccountId) {
|
||||
const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue();
|
||||
let end = _autoReplyDateValue(cfg?.end) || start;
|
||||
if (end < start) end = start;
|
||||
return {
|
||||
summary: 'Email Auto Reply (away)',
|
||||
dtstart: start,
|
||||
dtend: _addLocalCalendarDays(end, 1),
|
||||
all_day: true,
|
||||
description: `Odysseus email auto reply - account:${String(accountId || '')}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function _findAutoReplyCalendarEventUids(cfg, accountId) {
|
||||
const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue();
|
||||
const end = _autoReplyDateValue(cfg?.end) || start;
|
||||
const startYear = Number(start.slice(0, 4)) || new Date().getFullYear();
|
||||
const endYear = Math.max(startYear, Number(end.slice(0, 4)) || startYear);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/calendar/events?start=${startYear - 1}-01-01&end=${endYear + 2}-01-01`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const marker = `Odysseus email auto reply - account:${String(accountId || '')}`;
|
||||
return (data.events || [])
|
||||
.filter(ev => String(ev?.description || '').startsWith(marker))
|
||||
.map(ev => String(ev?.uid || '').trim())
|
||||
.filter(Boolean);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _syncAutoReplyCalendarEventNow(cfg, accountId) {
|
||||
accountId = String(accountId || '').trim();
|
||||
if (!accountId) return false;
|
||||
let uid = _readAutoReplyCalendarUid(accountId);
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const request = (url, options = {}) => fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: { ...headers, ...(options.headers || {}) },
|
||||
});
|
||||
|
||||
if (!cfg?.enabled) {
|
||||
const knownUids = new Set(uid ? [uid] : []);
|
||||
(await _findAutoReplyCalendarEventUids(cfg, accountId)).forEach(foundUid => knownUids.add(foundUid));
|
||||
for (const eventUid of knownUids) {
|
||||
const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(eventUid)}`, { method: 'DELETE' });
|
||||
if (!response.ok && response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`);
|
||||
}
|
||||
_writeAutoReplyCalendarUid(accountId, '');
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = _autoReplyCalendarPayload(cfg, accountId);
|
||||
if (!uid) {
|
||||
uid = (await _findAutoReplyCalendarEventUids(cfg, accountId))[0] || '';
|
||||
if (uid) _writeAutoReplyCalendarUid(accountId, uid);
|
||||
}
|
||||
if (uid) {
|
||||
const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
|
||||
method: 'PUT', body: JSON.stringify(payload),
|
||||
});
|
||||
if (response.ok) return true;
|
||||
if (response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`);
|
||||
_writeAutoReplyCalendarUid(accountId, '');
|
||||
}
|
||||
|
||||
const response = await request(`${API_BASE}/api/calendar/events`, {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data.uid) throw new Error(`Calendar HTTP ${response.status}`);
|
||||
_writeAutoReplyCalendarUid(accountId, data.uid);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function _syncAutoReplyCalendarEvent(cfg) {
|
||||
const accountId = String(state._libAccountId || '').trim();
|
||||
if (!accountId) return Promise.resolve(false);
|
||||
const previous = _autoReplyCalendarSyncs.get(accountId) || Promise.resolve();
|
||||
const next = previous.catch(() => {}).then(() => _syncAutoReplyCalendarEventNow(cfg, accountId));
|
||||
_autoReplyCalendarSyncs.set(accountId, next);
|
||||
return next.finally(() => {
|
||||
if (_autoReplyCalendarSyncs.get(accountId) === next) _autoReplyCalendarSyncs.delete(accountId);
|
||||
});
|
||||
}
|
||||
|
||||
export function _isAutoReplyActiveForCurrentAccount(cfg) {
|
||||
cfg = _normalizeAutoReplyConfig(cfg || {});
|
||||
if (!cfg.enabled) return false;
|
||||
if (cfg.scope === 'account' && cfg.accountId && cfg.accountId !== String(state._libAccountId || '')) return false;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const start = _autoReplyDateValue(cfg.start);
|
||||
const end = _autoReplyDateValue(cfg.end);
|
||||
if (start && today < start) return false;
|
||||
if (end && today > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function _syncEmailAutoReplyTitle(active) {
|
||||
const titleText = document.getElementById('email-lib-title-text');
|
||||
const statusDot = document.getElementById('email-lib-auto-reply-status-dot');
|
||||
const badge = document.getElementById('email-lib-auto-reply-badge');
|
||||
if (titleText) titleText.textContent = active ? 'Auto Reply - Active' : 'Email';
|
||||
if (statusDot) statusDot.style.display = active ? 'inline-block' : 'none';
|
||||
if (badge) {
|
||||
badge.style.display = 'none';
|
||||
badge.textContent = 'Auto Reply';
|
||||
}
|
||||
}
|
||||
|
||||
export async function _fetchEmailSettingsConfig() {
|
||||
const res = await fetch(emailApiUrl('/api/email/config', {
|
||||
account_id: state._libAccountId || undefined,
|
||||
}), { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const normalized = _normalizeAutoReplyConfig(await res.json());
|
||||
state._libViewInlineImages = normalized.viewInlineImages;
|
||||
_writeEmailInlineImagesPreference(state._libViewInlineImages);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function _fetchEmailWritingStyle() {
|
||||
const res = await fetch(emailApiUrl('/api/email/style', {
|
||||
account_id: state._libAccountId || undefined,
|
||||
}), { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return String(data.style || '');
|
||||
}
|
||||
|
||||
export function _emailSettingsLoadingHtml(label = 'Loading') {
|
||||
const wp = spinnerModule.createWhirlpool(16);
|
||||
const host = document.createElement('span');
|
||||
host.className = 'email-settings-loading-spinner';
|
||||
host.appendChild(wp.element);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'email-settings-loading';
|
||||
wrap.appendChild(host);
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
wrap.appendChild(text);
|
||||
return wrap.outerHTML;
|
||||
}
|
||||
|
||||
export function _emailSettingsFormHtml(cfg) {
|
||||
const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId);
|
||||
const accountAddress = activeAccount
|
||||
? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim()
|
||||
: '';
|
||||
const accountScope = accountAddress
|
||||
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="admin-card email-settings-section email-settings-auto-reply-section">
|
||||
<div class="email-settings-section-head">
|
||||
<div>
|
||||
<div class="email-settings-auto-reply-title-row">
|
||||
<div class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span>Auto Reply${accountScope}</span></div>
|
||||
<div class="email-settings-enabled-control">
|
||||
<span class="email-settings-enabled-state"></span>
|
||||
<label class="email-settings-toggle admin-switch">
|
||||
<input type="checkbox" id="email-auto-reply-enabled" ${cfg.enabled ? 'checked' : ''}>
|
||||
<span class="admin-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-settings-section-desc">Holiday or away replies for incoming mail.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="email-settings-auto-reply-body" class="email-settings-section-body">
|
||||
<div class="email-settings-grid">
|
||||
<label><span>Start date</span><input type="date" id="email-auto-reply-start" value="${_esc(_autoReplyDateValue(cfg.start))}"></label>
|
||||
<label><span>End date</span><input type="date" id="email-auto-reply-end" value="${_esc(_autoReplyDateValue(cfg.end))}"></label>
|
||||
</div>
|
||||
<label class="email-settings-field">
|
||||
<span>Subject</span>
|
||||
<input type="text" id="email-auto-reply-subject" value="${_esc(cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT)}" placeholder="${_esc(_DEFAULT_AUTO_REPLY_SUBJECT)}">
|
||||
</label>
|
||||
<label class="email-settings-field">
|
||||
<span>Message</span>
|
||||
<textarea id="email-auto-reply-message" rows="4">${_esc(cfg.message || _DEFAULT_AUTO_REPLY_MESSAGE)}</textarea>
|
||||
</label>
|
||||
<label class="email-settings-field">
|
||||
<span>Send to same sender</span>
|
||||
<select id="email-auto-reply-cooldown">
|
||||
<option value="period" ${cfg.cooldown === 'period' ? 'selected' : ''}>Once while active</option>
|
||||
<option value="1d" ${cfg.cooldown === '1d' ? 'selected' : ''}>Every day</option>
|
||||
<option value="3d" ${cfg.cooldown === '3d' ? 'selected' : ''}>Every 3 days</option>
|
||||
<option value="7d" ${cfg.cooldown === '7d' ? 'selected' : ''}>Every 7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="email-settings-check">
|
||||
<span>Pause email notifications while active</span>
|
||||
<span class="email-settings-check-spacer"></span>
|
||||
<span class="admin-switch"><input type="checkbox" id="email-auto-reply-pause" ${cfg.pauseNotifications ? 'checked' : ''}><span class="admin-slider"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function _emailCleanupSettingsHtml() {
|
||||
const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId);
|
||||
const accountAddress = activeAccount
|
||||
? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim()
|
||||
: '';
|
||||
const accountScope = accountAddress
|
||||
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="admin-card email-settings-section email-settings-cleanup-section">
|
||||
<div class="email-settings-section-head">
|
||||
<div>
|
||||
<span class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg><span>Newsletter Unsubscribe${accountScope}</span></span>
|
||||
<div class="email-settings-section-desc">Find newsletter and ad emails with unsubscribe options.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="email-settings-cleanup-body" class="email-settings-section-body">
|
||||
<div class="email-settings-clean-actions">
|
||||
<span class="email-unsub-status email-settings-clean-status" style="font-size:12px;color:color-mix(in srgb,var(--fg) 68%,transparent);"></span>
|
||||
<button type="button" class="memory-toolbar-btn email-settings-clean-btn">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:2px;color:var(--accent,var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
<span>Clean</span>
|
||||
</button>
|
||||
<button type="button" class="memory-toolbar-btn email-settings-save">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function _emailDisplaySettingsHtml(cfg = {}) {
|
||||
return `
|
||||
<div class="admin-card email-settings-section email-settings-display-section">
|
||||
<div class="email-settings-section-head">
|
||||
<div>
|
||||
<div class="email-settings-display-title-row">
|
||||
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82Z"/><path d="M7 7h.01"/></svg><span>Show Email Tags</span></div>
|
||||
<span class="email-settings-enabled-control">
|
||||
<span class="email-settings-enabled-state email-settings-display-enabled-state"></span>
|
||||
<label class="email-settings-toggle admin-switch">
|
||||
<input type="checkbox" id="email-settings-show-tags" ${state._libShowTags ? 'checked' : ''}>
|
||||
<span class="admin-slider"></span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="email-settings-section-desc email-settings-support-copy">Choose which labels appear on email cards. <span class="email-settings-display-task-note">Turn off tagging in <button type="button" class="email-settings-inline-link" data-open-email-tasks>Tasks</button></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-settings-display-spacer" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="admin-card email-settings-section email-settings-inline-images-section">
|
||||
<div class="email-settings-section-head">
|
||||
<div>
|
||||
<div class="email-settings-display-title-row">
|
||||
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg><span>Hide Inline Images</span></div>
|
||||
<span class="email-settings-enabled-control">
|
||||
<span class="email-settings-enabled-state email-settings-inline-images-enabled-state"></span>
|
||||
<label class="email-settings-toggle admin-switch">
|
||||
<input type="checkbox" id="email-settings-view-inline-images" ${cfg.viewInlineImages === false ? 'checked' : ''}>
|
||||
<span class="admin-slider"></span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="email-settings-section-desc email-settings-support-copy">Keep images embedded in email bodies hidden until you load them.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-settings-inline-images-spacer" aria-hidden="true"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function _emailSettingsAccountSelectHtml() {
|
||||
const accounts = Array.isArray(state._libAccounts)
|
||||
? state._libAccounts
|
||||
.filter(a => a && a.enabled !== false)
|
||||
.slice()
|
||||
.sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default))
|
||||
: [];
|
||||
if (!accounts.length) return '';
|
||||
const nameCounts = new Map();
|
||||
accounts.forEach(a => {
|
||||
const name = String(a.name || '').trim();
|
||||
if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
|
||||
});
|
||||
const opts = accounts.map(a => {
|
||||
const name = String(a.name || '').trim();
|
||||
const address = String(a.from_address || a.imap_user || '').trim();
|
||||
const label = name && nameCounts.get(name) > 1 && address
|
||||
? `${name} · ${address}`
|
||||
: (name || address || 'account');
|
||||
const suffix = a.is_default ? ' · (default)' : '';
|
||||
return `<option value="${_esc(a.id || '')}" ${state._libAccountId === a.id ? 'selected' : ''}>${_esc(label + suffix)}</option>`;
|
||||
}).join('');
|
||||
const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0];
|
||||
const active = !!selected?.is_default;
|
||||
const icon = active
|
||||
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
|
||||
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
|
||||
const defaultTitle = active ? 'Default account' : 'Make this the default account';
|
||||
return `<div class="email-settings-account-picker" style="margin-left:auto;display:inline-flex;align-items:center;gap:8px;">`
|
||||
+ `<span class="cookbook-srv-default email-settings-default${active ? ' active' : ''}" role="button" tabindex="0" aria-pressed="${active ? 'true' : 'false'}" data-email-default-id="${_esc(selected?.id || '')}" title="${defaultTitle}"><span class="cookbook-srv-default-icon" aria-hidden="true">${icon}</span><span class="cookbook-srv-default-label">default</span></span>`
|
||||
+ `<select id="email-settings-account-select" class="memory-toolbar-btn" title="Active email for all email settings" style="height:28px;max-width:240px;position:relative;top:-2px;">${opts}</select>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
export function _syncEmailSettingsAccountPicker(page) {
|
||||
const select = page?.querySelector('#email-settings-account-select');
|
||||
const selected = (state._libAccounts || []).find(a => a && a.id === state._libAccountId);
|
||||
if (select && selected) {
|
||||
[...select.options]
|
||||
.sort((a, b) => {
|
||||
const aAccount = (state._libAccounts || []).find(account => account && account.id === a.value);
|
||||
const bAccount = (state._libAccounts || []).find(account => account && account.id === b.value);
|
||||
return Number(!!bAccount?.is_default) - Number(!!aAccount?.is_default);
|
||||
})
|
||||
.forEach(option => select.appendChild(option));
|
||||
select.value = selected.id;
|
||||
[...select.options].forEach(option => {
|
||||
const account = (state._libAccounts || []).find(a => a && a.id === option.value);
|
||||
if (!account) return;
|
||||
const label = account.name || account.from_address || account.imap_user || 'account';
|
||||
option.textContent = label + (account.is_default ? ' · (default)' : '');
|
||||
});
|
||||
}
|
||||
const toggle = page?.querySelector('.email-settings-default');
|
||||
if (!toggle || !selected) return;
|
||||
const active = !!selected.is_default;
|
||||
toggle.classList.toggle('active', active);
|
||||
toggle.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
toggle.dataset.emailDefaultId = selected.id || '';
|
||||
toggle.title = active ? 'Default account' : 'Make this the default account';
|
||||
toggle.querySelector('.cookbook-srv-default-icon').innerHTML = active
|
||||
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
|
||||
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
|
||||
}
|
||||
|
||||
export function _emailWritingStyleHtml(style) {
|
||||
return `
|
||||
<div class="admin-card email-settings-section email-settings-style-section">
|
||||
<div class="email-settings-section-head">
|
||||
<div>
|
||||
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg><span>Email Reply Writing Style</span></div>
|
||||
<div class="email-settings-section-desc email-settings-support-copy">Used when Odysseus drafts replies for you.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="email-settings-style-body" class="email-settings-section-body">
|
||||
<label class="email-settings-field">
|
||||
<span>Writing style</span>
|
||||
<textarea id="email-writing-style-text" rows="6">${_esc(style)}</textarea>
|
||||
</label>
|
||||
<div class="email-settings-actions">
|
||||
<button type="button" class="memory-toolbar-btn email-style-settings-extract">
|
||||
<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
Extract
|
||||
</button>
|
||||
<button class="email-style-extract-help" aria-label="What does Extract do?" title="Extract analyzes sent emails from the selected account and saves a writing-style prompt for AI replies.">?</button>
|
||||
<button type="button" class="memory-toolbar-btn email-style-settings-save">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function _showEmailSettingsPage() {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
const page = document.getElementById('email-lib-settings-page');
|
||||
const btn = document.getElementById('email-lib-settings-btn');
|
||||
if (!modal || !page) return;
|
||||
modal.classList.add('email-settings-mode');
|
||||
page.hidden = false;
|
||||
page.scrollTop = 0;
|
||||
page.querySelector('.email-settings-body')?.scrollTo(0, 0);
|
||||
const headerBackBtn = document.getElementById('email-settings-header-back');
|
||||
if (headerBackBtn) headerBackBtn.style.display = 'inline-flex';
|
||||
btn?.classList.add('active');
|
||||
btn?.setAttribute('aria-expanded', 'true');
|
||||
page.innerHTML = `
|
||||
<div class="email-settings-page-head">
|
||||
<h2 class="email-settings-title">${_EMAIL_SETTINGS_ICON}<span>Email Settings</span></h2>
|
||||
</div>
|
||||
<div class="email-settings-body">
|
||||
${_emailSettingsLoadingHtml()}
|
||||
</div>
|
||||
`;
|
||||
let cfg;
|
||||
let writingStyle = '';
|
||||
try {
|
||||
await _loadAccounts().catch(() => {});
|
||||
const head = page.querySelector('.email-settings-page-head');
|
||||
const accountSelectHtml = _emailSettingsAccountSelectHtml();
|
||||
const oldPicker = page.querySelector('.email-settings-account-picker');
|
||||
if (oldPicker) oldPicker.remove();
|
||||
const title = head?.querySelector('.email-settings-title');
|
||||
if (title && accountSelectHtml) title.insertAdjacentHTML('beforeend', accountSelectHtml);
|
||||
[cfg, writingStyle] = await Promise.all([
|
||||
_fetchEmailSettingsConfig(),
|
||||
_fetchEmailWritingStyle().catch(() => ''),
|
||||
]);
|
||||
} catch (err) {
|
||||
page.querySelector('.email-settings-body').innerHTML = `<div class="email-settings-error">Could not load settings.</div>`;
|
||||
return;
|
||||
}
|
||||
page.querySelector('.email-settings-body').innerHTML = _emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg);
|
||||
page.querySelector('.email-settings-body')?.scrollTo(0, 0);
|
||||
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg);
|
||||
state._libAutoReplyDraftActive = null;
|
||||
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
|
||||
_renderAccountsStrip();
|
||||
_syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
|
||||
page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => {
|
||||
state._libAccountId = ev.currentTarget.value || null;
|
||||
state._libAutoReplyDraftActive = null;
|
||||
_publishActiveAccount();
|
||||
_syncEmailSettingsAccountPicker(page);
|
||||
_renderAccountsStrip();
|
||||
_resetEmailListForFreshLoad();
|
||||
_loadEmails({ useCache: true });
|
||||
_loadFolders({ resetMissing: true }).catch(() => {});
|
||||
page.querySelector('.email-unsubscribe-inline-panel')?.remove();
|
||||
const body = page.querySelector('.email-settings-body');
|
||||
if (body) body.innerHTML = _emailSettingsLoadingHtml();
|
||||
try {
|
||||
const [nextCfg, nextStyle] = await Promise.all([
|
||||
_fetchEmailSettingsConfig(),
|
||||
_fetchEmailWritingStyle().catch(() => ''),
|
||||
]);
|
||||
if (body) body.innerHTML = _emailWritingStyleHtml(nextStyle) + _emailDisplaySettingsHtml(nextCfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg);
|
||||
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(nextCfg);
|
||||
state._libAutoReplyDraftActive = null;
|
||||
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
|
||||
_renderAccountsStrip();
|
||||
_syncAutoReplyCalendarEvent(nextCfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
|
||||
_bindEmailSettingsPageControls(page);
|
||||
} catch (_) {
|
||||
if (body) body.innerHTML = `<div class="email-settings-error">Could not load settings.</div>`;
|
||||
}
|
||||
});
|
||||
page.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const acctId = ev.currentTarget.dataset.emailDefaultId;
|
||||
const account = (state._libAccounts || []).find(a => a && a.id === acctId);
|
||||
if (!acctId || account?.is_default) return;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
for (const a of state._libAccounts) a.is_default = a.id === acctId;
|
||||
_syncEmailSettingsAccountPicker(page);
|
||||
_renderAccountsStrip();
|
||||
} catch (err) {
|
||||
console.error('Set default account failed:', err);
|
||||
}
|
||||
});
|
||||
page.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
ev.preventDefault();
|
||||
ev.currentTarget.click();
|
||||
});
|
||||
_bindEmailSettingsPageControls(page);
|
||||
}
|
||||
|
||||
export function _bindEmailSettingsPageControls(page) {
|
||||
page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => {
|
||||
// Clean is an explicit fresh scan action; do not reopen stale cached
|
||||
// candidates when the user asks to clean again.
|
||||
_openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true });
|
||||
});
|
||||
const showTagsToggle = page.querySelector('#email-settings-show-tags');
|
||||
const syncDisplayTagsState = () => {
|
||||
const section = page.querySelector('.email-settings-display-section');
|
||||
const stateLabel = page.querySelector('.email-settings-display-enabled-state');
|
||||
const enabled = !!showTagsToggle?.checked;
|
||||
section?.classList.toggle('is-disabled', !enabled);
|
||||
section?.classList.toggle('is-enabled', enabled);
|
||||
if (stateLabel) stateLabel.textContent = enabled ? 'Show' : 'Hide';
|
||||
};
|
||||
showTagsToggle?.addEventListener('change', (ev) => {
|
||||
state._libShowTags = !!ev.target.checked;
|
||||
localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0');
|
||||
_renderGrid();
|
||||
document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } }));
|
||||
if (state._libShowTags && !_loadedEmailsHaveVisibleTags()) _notifyNoLoadedEmailTags();
|
||||
syncDisplayTagsState();
|
||||
});
|
||||
syncDisplayTagsState();
|
||||
const inlineImagesToggle = page.querySelector('#email-settings-view-inline-images');
|
||||
const syncInlineImagesState = () => {
|
||||
const section = page.querySelector('.email-settings-inline-images-section');
|
||||
const stateLabel = page.querySelector('.email-settings-inline-images-enabled-state');
|
||||
const hidden = !!inlineImagesToggle?.checked;
|
||||
section?.classList.toggle('is-disabled', !hidden);
|
||||
section?.classList.toggle('is-enabled', hidden);
|
||||
if (stateLabel) stateLabel.textContent = hidden ? 'Hide' : 'Show';
|
||||
};
|
||||
inlineImagesToggle?.addEventListener('change', async (ev) => {
|
||||
const hidden = !!ev.target.checked;
|
||||
const enabled = !hidden;
|
||||
const previous = state._libViewInlineImages;
|
||||
state._libViewInlineImages = enabled;
|
||||
_writeEmailInlineImagesPreference(enabled);
|
||||
syncInlineImagesState();
|
||||
try {
|
||||
const res = await fetch(emailApiUrl('/api/email/config', {
|
||||
account_id: state._libAccountId || undefined,
|
||||
}), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email_view_inline_images: enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
document.querySelectorAll('.email-card-reader').forEach(reader => _wireEmailInlineImages(reader));
|
||||
} catch (err) {
|
||||
state._libViewInlineImages = previous;
|
||||
_writeEmailInlineImagesPreference(previous);
|
||||
ev.target.checked = previous === false;
|
||||
syncInlineImagesState();
|
||||
showToast?.('Failed to save inline image setting');
|
||||
console.error('Failed to save inline image setting:', err);
|
||||
}
|
||||
});
|
||||
syncInlineImagesState();
|
||||
page.querySelector('[data-open-email-tasks]')?.addEventListener('click', _openTasksForEmailTags);
|
||||
const autoReplyStart = page.querySelector('#email-auto-reply-start');
|
||||
const seedAutoReplyStartDate = () => {
|
||||
if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();
|
||||
};
|
||||
// Empty native date inputs can open on an arbitrary browser baseline date.
|
||||
// Seed only the draft field when the picker is opened, leaving saved settings
|
||||
// unchanged until the user explicitly presses Save.
|
||||
autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);
|
||||
autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);
|
||||
const enabledToggle = page.querySelector('#email-auto-reply-enabled');
|
||||
const syncEnabledState = () => {
|
||||
const section = page.querySelector('.email-settings-auto-reply-section');
|
||||
const stateLabel = section?.querySelector('.email-settings-enabled-state');
|
||||
const enabled = !!enabledToggle?.checked;
|
||||
section?.classList.toggle('is-disabled', !enabled);
|
||||
section?.classList.toggle('is-enabled', enabled);
|
||||
if (stateLabel) stateLabel.textContent = enabled ? 'Enabled' : 'Disabled';
|
||||
};
|
||||
const saveAutoReplySettings = async ({ quiet = false } = {}) => {
|
||||
const saveBtn = page.querySelector('.email-settings-save');
|
||||
const status = page.querySelector('.email-settings-status');
|
||||
if (saveBtn && !quiet) saveBtn.disabled = true;
|
||||
_setEmailSaveIcon(saveBtn, false);
|
||||
if (status) {
|
||||
status.classList.remove('is-success', 'is-error');
|
||||
status.textContent = 'Saving…';
|
||||
}
|
||||
const payload = {
|
||||
email_auto_reply: !!page.querySelector('#email-auto-reply-enabled')?.checked,
|
||||
email_auto_reply_start: page.querySelector('#email-auto-reply-start')?.value || '',
|
||||
email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '',
|
||||
email_auto_reply_subject: page.querySelector('#email-auto-reply-subject')?.value || '',
|
||||
email_auto_reply_message: page.querySelector('#email-auto-reply-message')?.value || '',
|
||||
email_auto_reply_cooldown: page.querySelector('#email-auto-reply-cooldown')?.value || 'period',
|
||||
email_auto_reply_scope: 'account',
|
||||
email_auto_reply_account_id: state._libAccountId || '',
|
||||
email_auto_reply_pause_notifications: !!page.querySelector('#email-auto-reply-pause')?.checked,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(emailApiUrl('/api/email/config', {
|
||||
account_id: state._libAccountId || undefined,
|
||||
}), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const savedCfg = {
|
||||
enabled: !!payload.email_auto_reply,
|
||||
start: payload.email_auto_reply_start,
|
||||
end: payload.email_auto_reply_end,
|
||||
};
|
||||
let calendarSynced = true;
|
||||
try {
|
||||
await _syncAutoReplyCalendarEvent(savedCfg);
|
||||
} catch (calendarErr) {
|
||||
calendarSynced = false;
|
||||
console.warn('Failed to sync auto-reply calendar event:', calendarErr);
|
||||
}
|
||||
if (status) {
|
||||
status.classList.add('is-success');
|
||||
status.textContent = 'Saved';
|
||||
}
|
||||
_setEmailSaveIcon(saveBtn, true);
|
||||
if (!quiet) showToast?.('Email settings saved');
|
||||
if (!calendarSynced) showToast?.('Saved, but the calendar event could not be updated');
|
||||
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(savedCfg);
|
||||
state._libAutoReplyDraftActive = null;
|
||||
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
|
||||
_renderAccountsStrip();
|
||||
_refreshUnreadBadge({ preserveAutoReplyTitle: true }).catch(() => {});
|
||||
setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, quiet ? 900 : 1400);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (status) {
|
||||
status.classList.add('is-error');
|
||||
status.textContent = 'Save failed';
|
||||
}
|
||||
showToast?.('Failed to save email settings');
|
||||
return false;
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
enabledToggle?.addEventListener('change', () => {
|
||||
syncEnabledState();
|
||||
// Invalidate any config read that started before this toggle. Its stale
|
||||
// result must not overwrite the title we are about to render.
|
||||
state._autoReplyRefreshSeq += 1;
|
||||
const active = _isAutoReplyActiveForCurrentAccount({
|
||||
email_auto_reply: !!enabledToggle.checked,
|
||||
email_auto_reply_start: autoReplyStart?.value || '',
|
||||
email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '',
|
||||
email_auto_reply_scope: 'account',
|
||||
email_auto_reply_account_id: state._libAccountId || '',
|
||||
});
|
||||
state._libAutoReplyActive = active;
|
||||
state._libAutoReplyDraftActive = active;
|
||||
_syncEmailAutoReplyTitle(active);
|
||||
_renderAccountsStrip();
|
||||
saveAutoReplySettings({ quiet: true });
|
||||
});
|
||||
syncEnabledState();
|
||||
page.querySelector('.email-settings-save')?.addEventListener('click', async () => {
|
||||
await saveAutoReplySettings();
|
||||
});
|
||||
page.querySelector('.email-style-settings-save')?.addEventListener('click', async () => {
|
||||
const saveBtn = page.querySelector('.email-style-settings-save');
|
||||
const status = page.querySelector('.email-style-settings-status');
|
||||
saveBtn.disabled = true;
|
||||
_setEmailSaveIcon(saveBtn, false);
|
||||
if (status) {
|
||||
status.classList.remove('is-success', 'is-error');
|
||||
status.textContent = 'Saving…';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(emailApiUrl('/api/email/style', {
|
||||
account_id: state._libAccountId || undefined,
|
||||
}), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ style: page.querySelector('#email-writing-style-text')?.value || '' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (status) {
|
||||
status.classList.add('is-success');
|
||||
status.textContent = 'Saved';
|
||||
}
|
||||
_setEmailSaveIcon(saveBtn, true);
|
||||
showToast?.('Email writing style saved');
|
||||
setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, 1400);
|
||||
} catch (err) {
|
||||
if (status) {
|
||||
status.classList.add('is-error');
|
||||
status.textContent = 'Save failed';
|
||||
}
|
||||
showToast?.('Failed to save email writing style');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
page.querySelector('.email-style-settings-extract')?.addEventListener('click', async () => {
|
||||
const extractBtn = page.querySelector('.email-style-settings-extract');
|
||||
const saveBtn = page.querySelector('.email-style-settings-save');
|
||||
const status = page.querySelector('.email-style-settings-status');
|
||||
const styleEl = page.querySelector('#email-writing-style-text');
|
||||
extractBtn.disabled = true;
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
let wp = null;
|
||||
if (status) {
|
||||
status.innerHTML = '';
|
||||
try {
|
||||
wp = spinnerModule.createWhirlpool(14);
|
||||
wp.element.style.cssText = 'display:inline-block;vertical-align:-3px;margin-right:6px;';
|
||||
status.appendChild(wp.element);
|
||||
status.appendChild(document.createTextNode('Analyzing sent emails…'));
|
||||
} catch (_) {
|
||||
status.textContent = 'Analyzing sent emails…';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(emailApiUrl('/api/email/extract-style', {
|
||||
account_id: state._libAccountId || undefined,
|
||||
}), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ sample_count: 15 }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.success || !data.style) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
if (styleEl) styleEl.value = data.style;
|
||||
if (status) status.textContent = 'Extracted';
|
||||
showToast?.('Email writing style extracted');
|
||||
setTimeout(() => { if (status) status.textContent = ''; }, 1800);
|
||||
} catch (err) {
|
||||
if (status) status.textContent = err?.message || 'Extract failed';
|
||||
showToast?.('Failed to extract email writing style');
|
||||
} finally {
|
||||
if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} }
|
||||
extractBtn.disabled = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function _hideEmailSettingsPage() {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
const page = document.getElementById('email-lib-settings-page');
|
||||
const btn = document.getElementById('email-lib-settings-btn');
|
||||
const headerBackBtn = document.getElementById('email-settings-header-back');
|
||||
modal?.classList.remove('email-settings-mode');
|
||||
if (page) page.hidden = true;
|
||||
if (headerBackBtn) headerBackBtn.style.display = 'none';
|
||||
btn?.classList.remove('active');
|
||||
btn?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
@@ -36,4 +36,8 @@ export const state = {
|
||||
_libEscHandler: null,
|
||||
_selectMode: false,
|
||||
_selectedUids: new Set(),
|
||||
// Generation guard for the away/auto-reply refresh. Written by the
|
||||
// settings page and by the unread-badge refresh, which live in
|
||||
// different modules, so it cannot be a module-level `let`.
|
||||
_autoReplyRefreshSeq: 0,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+29
-575
@@ -4,24 +4,23 @@
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import searchModule from './search.js';
|
||||
import { byId } from './settings/dom.js';
|
||||
import {
|
||||
getSettingsRegistryIssues,
|
||||
isAdminManagedSettingsTab,
|
||||
} from './settings/registry.js';
|
||||
import { postSettings as _postSettings } from './settings/api.js';
|
||||
import { initTtsSettings, initSttSettings } from './settings/speech.js';
|
||||
import { initDocumentWritingStyle } from './settings/writingStyle.js';
|
||||
import { initImageSettings } from './settings/imageModels.js';
|
||||
import { initAgentSettings } from './settings/agent.js';
|
||||
import { getSettingsRegistryIssues } from './settings/registry.js';
|
||||
import { bindSettingsSearch } from './settings/search.js';
|
||||
import { bindSettingsSidebar } from './settings/sidebar.js';
|
||||
import {
|
||||
activateSettingsPanel,
|
||||
getActiveSettingsTab,
|
||||
bindSettingsNavigation,
|
||||
} from './settings/navigation.js';
|
||||
import { bindSettingsNavigation } from './settings/navigation.js';
|
||||
import {
|
||||
bindSettingsDrag,
|
||||
bindSettingsClose,
|
||||
bindOpenPromptModalLink,
|
||||
showSettingsModal,
|
||||
hideSettingsModal,
|
||||
} from './settings/lifecycle.js';
|
||||
import { bindSettingsPeekToggle } from './settings/peek.js';
|
||||
import { createSettingsShell } from './settings/shell.js';
|
||||
import { handleSettingsOauthReturn } from './settings/oauthReturn.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
@@ -45,18 +44,6 @@ let _authPolicy = { password_min_length: 8 };
|
||||
* Reads in this file deliberately stay direct fetches: this panel is the writer
|
||||
* and edits what it reads, so it must see the authoritative state, not a cache.
|
||||
*/
|
||||
async function _postSettings(body) {
|
||||
try {
|
||||
return await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
invalidateSettings();
|
||||
}
|
||||
}
|
||||
|
||||
const el = byId;
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
@@ -65,79 +52,18 @@ function safeRasterDataUrl(raw) {
|
||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/* ── Settings shell coordination ── */
|
||||
function onSettingsPanelActivated(tab) {
|
||||
// Appearance keeps its existing transparent preview behavior.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
|
||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
}
|
||||
|
||||
function openAdminSettingsTab(tab) {
|
||||
if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open(tab);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Appearance-tab opacity slider ──
|
||||
Mirrors the Theme customizer's slider: fades the settings modal's
|
||||
background (and inner cards) via color-mix so the user can watch the
|
||||
rest of the UI react to toggles, while keeping text/controls crisp
|
||||
(no element opacity). Only shown/active on the Appearance tab. */
|
||||
const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on
|
||||
function _applySettingsOpacity(on) {
|
||||
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
|
||||
if (!content) return;
|
||||
const cards = content.querySelectorAll('.admin-card');
|
||||
if (on) {
|
||||
const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`;
|
||||
const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`;
|
||||
content.style.setProperty('background', bgMix, 'important');
|
||||
content.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
cards.forEach(c => {
|
||||
c.style.setProperty('background', panelMix, 'important');
|
||||
c.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
});
|
||||
} else {
|
||||
content.style.removeProperty('background');
|
||||
content.style.removeProperty('backdrop-filter');
|
||||
content.style.removeProperty('-webkit-backdrop-filter');
|
||||
cards.forEach(c => {
|
||||
c.style.removeProperty('background');
|
||||
c.style.removeProperty('backdrop-filter');
|
||||
c.style.removeProperty('-webkit-backdrop-filter');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
|
||||
function syncAppearanceOpacity(active) {
|
||||
const toggle = el('settings-opacity-wrap');
|
||||
if (toggle) toggle.classList.toggle('hidden', !active);
|
||||
if (active) {
|
||||
_applySettingsOpacity(toggle ? toggle.classList.contains('active') : false);
|
||||
} else {
|
||||
_applySettingsOpacity(false); // clear the fade off the Appearance tab
|
||||
}
|
||||
}
|
||||
|
||||
function initOpacityToggle() {
|
||||
const toggle = el('settings-opacity-wrap');
|
||||
if (!toggle || toggle.dataset.bound === '1') return;
|
||||
toggle.dataset.bound = '1';
|
||||
toggle.addEventListener('click', () => {
|
||||
const on = !toggle.classList.contains('active');
|
||||
toggle.classList.toggle('active', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
_applySettingsOpacity(on);
|
||||
});
|
||||
}
|
||||
/* ── Settings shell coordination ──
|
||||
Panel activation side effects, the admin handoff, admin-only visibility and
|
||||
the public open/close API live in ./settings/shell.js. This coordinator only
|
||||
supplies the panel-owned pieces the shell needs. */
|
||||
const _shell = createSettingsShell({
|
||||
getModal: () => modalEl,
|
||||
ensureInitialized: () => { if (!initialized) initAll(); },
|
||||
syncAppearanceCheckboxes: () => syncAppearanceCheckboxes(),
|
||||
refreshAiModelEndpoints: () => refreshAiModelEndpoints(),
|
||||
isAdmin: () => !!window._isAdmin,
|
||||
getAdminModule: () => window.adminModule,
|
||||
});
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
AI TAB
|
||||
@@ -602,56 +528,6 @@ async function initTeacherModel() {
|
||||
}
|
||||
|
||||
/* ── Image Generation ── */
|
||||
async function initImageSettings() {
|
||||
const modelSel = el('set-imgModelSelect');
|
||||
const qualSel = el('set-imgQualitySelect');
|
||||
const msg = el('set-imgSettingsMsg');
|
||||
const enabledToggle = el('set-imgEnabledToggle');
|
||||
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
|
||||
try {
|
||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
|
||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||
if (modelId) imageModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mid;
|
||||
opt.textContent = mid;
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
if (settings.image_model) modelSel.value = settings.image_model;
|
||||
if (settings.image_quality) qualSel.value = settings.image_quality;
|
||||
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
||||
} catch (e) { console.warn('Failed to load settings', e); }
|
||||
|
||||
function syncImgDisabled() {
|
||||
var off = enabledToggle && !enabledToggle.checked;
|
||||
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncImgDisabled();
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
modelSel.addEventListener('change', saveSettings);
|
||||
qualSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
||||
}
|
||||
|
||||
/* ── Vision ── */
|
||||
async function initVisionSettings() {
|
||||
@@ -733,246 +609,8 @@ async function initVisionSettings() {
|
||||
/* ── Face Recognition ── */
|
||||
|
||||
/* ── Text to Speech ── */
|
||||
async function initTtsSettings() {
|
||||
var provSel = el('set-ttsProviderSelect');
|
||||
var modelSelect = el('set-ttsModelSelect');
|
||||
var modelInput = el('set-ttsModelInput');
|
||||
var voiceSelect = el('set-ttsVoiceSelect');
|
||||
var voiceInput = el('set-ttsVoiceInput');
|
||||
var modelRow = el('set-ttsModelRow');
|
||||
var voiceRow = el('set-ttsVoiceRow');
|
||||
var speedSelect = el('set-ttsSpeedSelect');
|
||||
var speedRow = el('set-ttsSpeedRow');
|
||||
var ttsMsg = el('set-ttsSettingsMsg');
|
||||
var ttsEnabledToggle = el('set-ttsEnabledToggle');
|
||||
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
|
||||
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
|
||||
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
|
||||
} else {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
var ttsKeywords = ['tts', 'audio'];
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
|
||||
if (!hasTTS) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
|
||||
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.tts_provider) provSel.value = settings.tts_provider;
|
||||
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
|
||||
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
|
||||
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load TTS settings', e); }
|
||||
|
||||
function syncTtsDisabled() {
|
||||
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
|
||||
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncTtsDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveTTS() {
|
||||
try {
|
||||
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
|
||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
async function saveAndClearCache() {
|
||||
await saveTTS();
|
||||
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() {
|
||||
var prov = provSel.value;
|
||||
if (prov === 'local') voiceInput.value = 'af_heart';
|
||||
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
|
||||
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
|
||||
updateVisibility();
|
||||
saveTTS();
|
||||
});
|
||||
modelSelect.addEventListener('change', saveAndClearCache);
|
||||
modelInput.addEventListener('change', saveTTS);
|
||||
voiceSelect.addEventListener('change', saveAndClearCache);
|
||||
voiceInput.addEventListener('change', saveTTS);
|
||||
speedSelect.addEventListener('change', saveAndClearCache);
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
|
||||
|
||||
// Preview / test button
|
||||
var previewBtn = el('set-ttsPreviewBtn');
|
||||
if (previewBtn) {
|
||||
var previewAudio = null;
|
||||
var previewPlaying = false;
|
||||
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
|
||||
|
||||
previewBtn.addEventListener('click', async function() {
|
||||
if (previewPlaying) {
|
||||
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
|
||||
window.speechSynthesis.cancel();
|
||||
resetPreview(); return;
|
||||
}
|
||||
var prov = provSel.value;
|
||||
if (prov === 'disabled') {
|
||||
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
|
||||
}
|
||||
var testText = 'Hello, this is a test of text to speech.';
|
||||
previewPlaying = true; previewBtn.textContent = 'Loading...';
|
||||
try {
|
||||
if (prov === 'browser') {
|
||||
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
|
||||
var utt = new SpeechSynthesisUtterance(testText);
|
||||
var voiceVal = getVoice();
|
||||
if (voiceVal) {
|
||||
var voices = window.speechSynthesis.getVoices();
|
||||
var target = voiceVal.toLowerCase();
|
||||
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
|
||||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
|
||||
if (match) utt.voice = match;
|
||||
}
|
||||
utt.rate = parseFloat(speedSelect.value) || 1;
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
utt.onend = resolve;
|
||||
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
});
|
||||
} else {
|
||||
var res = await fetch('/api/tts/synthesize', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText, format: 'audio' })
|
||||
});
|
||||
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
|
||||
var blob = await res.blob();
|
||||
var url = URL.createObjectURL(blob);
|
||||
previewAudio = new Audio(url);
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
|
||||
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
|
||||
previewAudio.play().catch(reject);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
|
||||
} finally {
|
||||
resetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Speech to Text ── */
|
||||
async function initSttSettings() {
|
||||
var provSel = el('set-sttProviderSelect');
|
||||
var modelSelect = el('set-sttModelSelect');
|
||||
var modelInput = el('set-sttModelInput');
|
||||
var modelRow = el('set-sttModelRow');
|
||||
var langRow = el('set-sttLangRow');
|
||||
var langInput = el('set-sttLangInput');
|
||||
var sttMsg = el('set-sttSettingsMsg');
|
||||
var sttEnabledToggle = el('set-sttEnabledToggle');
|
||||
var sttConfigWrap = el('set-sttConfigWrap');
|
||||
// STT was removed from AI Defaults — bail if the UI isn't present.
|
||||
if (!provSel) return;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
var showModel = prov === 'local' || prov.startsWith('endpoint:');
|
||||
var showLang = prov !== 'disabled';
|
||||
modelRow.style.display = showModel ? 'flex' : 'none';
|
||||
langRow.style.display = showLang ? 'flex' : 'none';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
} else {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function syncSttDisabled() {
|
||||
var off = sttEnabledToggle && !sttEnabledToggle.checked;
|
||||
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
|
||||
// Effective provider: if toggle is off, treat as disabled regardless of provider select
|
||||
function effectiveProvider() {
|
||||
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
|
||||
return provSel.value;
|
||||
}
|
||||
|
||||
// Add API endpoints that might support STT
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.stt_provider) provSel.value = settings.stt_provider;
|
||||
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
|
||||
if (settings.stt_language) langInput.value = settings.stt_language;
|
||||
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load STT settings', e); }
|
||||
|
||||
syncSttDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveSTT() {
|
||||
try {
|
||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
|
||||
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
||||
// Notify voiceRecorder of effective provider and update send button icon
|
||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
|
||||
modelSelect.addEventListener('change', saveSTT);
|
||||
modelInput.addEventListener('change', saveSTT);
|
||||
langInput.addEventListener('change', saveSTT);
|
||||
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
SEARCH TAB
|
||||
@@ -1494,56 +1132,6 @@ async function initResearchSearchSettings() {
|
||||
}
|
||||
|
||||
/* ── Agent Settings (AI tab) ── */
|
||||
async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
APPEARANCE TAB
|
||||
@@ -2141,8 +1729,8 @@ function initAll() {
|
||||
modalEl = el('settings-modal');
|
||||
|
||||
bindSettingsNavigation(modalEl, {
|
||||
openAdminTab: openAdminSettingsTab,
|
||||
onPanelActivated: onSettingsPanelActivated,
|
||||
openAdminTab: _shell.openAdminTab,
|
||||
onPanelActivated: _shell.onPanelActivated,
|
||||
});
|
||||
|
||||
bindSettingsSearch(modalEl, {
|
||||
@@ -2172,7 +1760,7 @@ function initAll() {
|
||||
closeSettings: close,
|
||||
});
|
||||
|
||||
initOpacityToggle();
|
||||
bindSettingsPeekToggle(modalEl);
|
||||
initialized = true;
|
||||
initDefaultChat();
|
||||
initTeacherModel();
|
||||
@@ -2196,69 +1784,6 @@ function initAll() {
|
||||
initDocumentWritingStyle();
|
||||
}
|
||||
|
||||
async function initDocumentWritingStyle() {
|
||||
const styleEl = el('set-document-style');
|
||||
const saveBtn = el('set-document-style-save');
|
||||
const extractBtn = el('set-document-style-extract');
|
||||
const fileEl = el('set-document-style-file');
|
||||
const msg = el('set-document-style-msg');
|
||||
if (!styleEl || !saveBtn) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
styleEl.value = String(data.document_writing_style || '');
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to load';
|
||||
}
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const res = await _postSettings({ document_writing_style: styleEl.value });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
extractBtn?.addEventListener('click', () => fileEl?.click());
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
const file = fileEl.files?.[0];
|
||||
if (!file) return;
|
||||
extractBtn.disabled = true;
|
||||
let whirlpool = null;
|
||||
if (msg) {
|
||||
msg.replaceChildren();
|
||||
try {
|
||||
const spinner = window.spinnerModule || (await import('./spinner.js')).default;
|
||||
whirlpool = spinner.createWhirlpool(14);
|
||||
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Analyzing...';
|
||||
msg.append(whirlpool.element, label);
|
||||
} catch (_) {
|
||||
msg.textContent = 'Analyzing...';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch('/api/auth/settings/document-style/extract', {
|
||||
method: 'POST', credentials: 'same-origin', body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
|
||||
styleEl.value = String(data.style);
|
||||
if (msg) msg.textContent = '✓ Extracted — review and save';
|
||||
} catch (error) {
|
||||
if (msg) msg.textContent = error.message || 'Extraction failed';
|
||||
} finally {
|
||||
whirlpool?.destroy?.();
|
||||
extractBtn.disabled = false;
|
||||
fileEl.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyIntegrationsChanged() {
|
||||
try {
|
||||
@@ -5644,85 +5169,14 @@ async function initUnifiedIntegrations() {
|
||||
await renderList();
|
||||
}
|
||||
|
||||
/* ── Admin visibility sync ── */
|
||||
function syncAdminVisibility() {
|
||||
if (!modalEl) return;
|
||||
const isAdmin = !!window._isAdmin;
|
||||
modalEl.querySelectorAll('.admin-only').forEach(el => {
|
||||
el.style.display = isAdmin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PUBLIC API
|
||||
═══════════════════════════════════════════ */
|
||||
export function open(tab) {
|
||||
if (!initialized) initAll();
|
||||
export function open(tab) { _shell.open(tab); }
|
||||
export function close() { _shell.close(); }
|
||||
function syncAdminVisibility() { _shell.syncAdminVisibility(); }
|
||||
|
||||
syncAppearanceCheckboxes();
|
||||
showSettingsModal(modalEl);
|
||||
syncAdminVisibility();
|
||||
|
||||
if (tab) {
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
}
|
||||
|
||||
// Preserve existing panel-specific side effects when Settings is opened
|
||||
// directly to a tab as well as when the user navigates there.
|
||||
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||
onSettingsPanelActivated(activeTab);
|
||||
|
||||
// Auto-init admin data if showing an admin tab.
|
||||
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
window.adminModule._initData();
|
||||
}
|
||||
}
|
||||
|
||||
export function close() {
|
||||
if (!modalEl) return;
|
||||
|
||||
// Always clear the Appearance state so the rest of the app does not remain
|
||||
// dimmed if Settings is closed while that panel is active.
|
||||
document.body.classList.remove('settings-appearance-open');
|
||||
syncAppearanceOpacity(false);
|
||||
|
||||
hideSettingsModal(modalEl);
|
||||
}
|
||||
|
||||
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
||||
(function _handleOauthRedirect() {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||
// Strip params from URL without a page reload.
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
// Open settings → integrations once the document is ready. This module owns
|
||||
// the open() API, so it does not need to wait for a window-level alias.
|
||||
function _showResult() {
|
||||
open('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? 'Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||
} else {
|
||||
_showResult();
|
||||
}
|
||||
})();
|
||||
handleSettingsOauthReturn({ openSettings: open });
|
||||
|
||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Agent settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Lifted verbatim from static/js/settings.js so extracted panels can post
|
||||
// settings without importing the module they were extracted from.
|
||||
import { invalidateSettings } from '../appConfig.js';
|
||||
|
||||
export async function postSettings(body) {
|
||||
try {
|
||||
return await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
invalidateSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Image-model settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
export async function initImageSettings() {
|
||||
const modelSel = el('set-imgModelSelect');
|
||||
const qualSel = el('set-imgQualitySelect');
|
||||
const msg = el('set-imgSettingsMsg');
|
||||
const enabledToggle = el('set-imgEnabledToggle');
|
||||
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
|
||||
try {
|
||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
|
||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||
if (modelId) imageModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mid;
|
||||
opt.textContent = mid;
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
if (settings.image_model) modelSel.value = settings.image_model;
|
||||
if (settings.image_quality) qualSel.value = settings.image_quality;
|
||||
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
||||
} catch (e) { console.warn('Failed to load settings', e); }
|
||||
|
||||
function syncImgDisabled() {
|
||||
var off = enabledToggle && !enabledToggle.checked;
|
||||
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncImgDisabled();
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
modelSel.addEventListener('change', saveSettings);
|
||||
qualSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Return path from the Google OAuth2 redirect.
|
||||
//
|
||||
// The provider sends the browser back to the app root with a result in the
|
||||
// query string, so something has to notice on load and put the user back where
|
||||
// they were — the Integrations panel — with the outcome visible. That is
|
||||
// navigation, not an Integrations concern, and it runs once per page load
|
||||
// rather than once per panel open.
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {(tab: string) => void} options.openSettings the Settings open() entry point
|
||||
*/
|
||||
export function handleSettingsOauthReturn({ openSettings } = {}) {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||
// Strip params from URL without a page reload.
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
|
||||
// The caller passes its own open() in, so this never has to wait for a
|
||||
// window-level alias to exist.
|
||||
function _showResult() {
|
||||
openSettings('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? 'Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||
} else {
|
||||
_showResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Settings window peek (transparency) chrome.
|
||||
//
|
||||
// The Appearance panel lets the user watch the rest of the UI react to a toggle
|
||||
// without closing Settings. That is window chrome, not panel data, so it lives
|
||||
// here rather than with the Appearance panel's checkbox handling: the fade is
|
||||
// applied to the Settings window's own background via color-mix — never element
|
||||
// opacity, so controls and text stay crisp — and it has to be cleared the
|
||||
// moment the user leaves Appearance or closes the window. Mirrors the Theme
|
||||
// customizer's slider.
|
||||
|
||||
import { byId } from './dom.js';
|
||||
|
||||
const PEEK_OPACITY = 55; // % opacity when the Peek toggle is on
|
||||
const TOGGLE_ID = 'settings-opacity-wrap';
|
||||
|
||||
function _toggleEl() {
|
||||
return byId(TOGGLE_ID);
|
||||
}
|
||||
|
||||
export function applySettingsPeek(modalEl, on) {
|
||||
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
|
||||
if (!content) return;
|
||||
const cards = content.querySelectorAll('.admin-card');
|
||||
if (on) {
|
||||
const bgMix = `color-mix(in srgb, var(--bg) ${PEEK_OPACITY}%, transparent)`;
|
||||
const panelMix = `color-mix(in srgb, var(--panel) ${PEEK_OPACITY}%, transparent)`;
|
||||
content.style.setProperty('background', bgMix, 'important');
|
||||
content.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
cards.forEach(c => {
|
||||
c.style.setProperty('background', panelMix, 'important');
|
||||
c.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
});
|
||||
} else {
|
||||
content.style.removeProperty('background');
|
||||
content.style.removeProperty('backdrop-filter');
|
||||
content.style.removeProperty('-webkit-backdrop-filter');
|
||||
cards.forEach(c => {
|
||||
c.style.removeProperty('background');
|
||||
c.style.removeProperty('backdrop-filter');
|
||||
c.style.removeProperty('-webkit-backdrop-filter');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
|
||||
export function syncSettingsPeek(modalEl, active) {
|
||||
const toggle = _toggleEl();
|
||||
if (toggle) toggle.classList.toggle('hidden', !active);
|
||||
if (active) {
|
||||
applySettingsPeek(modalEl, toggle ? toggle.classList.contains('active') : false);
|
||||
} else {
|
||||
applySettingsPeek(modalEl, false); // clear the fade off the Appearance tab
|
||||
}
|
||||
}
|
||||
|
||||
export function bindSettingsPeekToggle(modalEl) {
|
||||
const toggle = _toggleEl();
|
||||
if (!toggle || toggle.dataset.bound === '1') return;
|
||||
toggle.dataset.bound = '1';
|
||||
toggle.addEventListener('click', () => {
|
||||
const on = !toggle.classList.contains('active');
|
||||
toggle.classList.toggle('active', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
applySettingsPeek(modalEl, on);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Settings shell coordination.
|
||||
//
|
||||
// settings.js still owns every panel's data loading. This module owns the shell
|
||||
// around them: what happens when a panel becomes active, where an
|
||||
// admin-managed tab is handed off to admin.js, which elements are admin-only,
|
||||
// and the public open/close entry points.
|
||||
//
|
||||
// It keeps no panel state. Everything it needs from the panels arrives through
|
||||
// the options below, so panels can move out of settings.js one at a time
|
||||
// without this file changing — and so the shell's behavior is testable without
|
||||
// booting a panel.
|
||||
|
||||
import { activateSettingsPanel, getActiveSettingsTab } from './navigation.js';
|
||||
import { showSettingsModal, hideSettingsModal } from './lifecycle.js';
|
||||
import { isAdminManagedSettingsTab } from './registry.js';
|
||||
import { syncSettingsPeek } from './peek.js';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {() => Element|null} options.getModal the Settings modal, once initialized
|
||||
* @param {() => void} options.ensureInitialized first-open panel initialization
|
||||
* @param {() => void} options.syncAppearanceCheckboxes Appearance panel state refresh
|
||||
* @param {() => void} options.refreshAiModelEndpoints AI panel endpoint refresh
|
||||
* @param {() => boolean} options.isAdmin current admin status
|
||||
* @param {() => object|null} options.getAdminModule the lazily loaded admin module
|
||||
*/
|
||||
export function createSettingsShell(options = {}) {
|
||||
const getModal = options.getModal;
|
||||
const ensureInitialized = options.ensureInitialized;
|
||||
const syncAppearanceCheckboxes = options.syncAppearanceCheckboxes;
|
||||
const refreshAiModelEndpoints = options.refreshAiModelEndpoints;
|
||||
const isAdmin = options.isAdmin;
|
||||
const getAdminModule = options.getAdminModule;
|
||||
|
||||
const _modal = () => (typeof getModal === 'function' ? getModal() : null);
|
||||
const _admin = () => (typeof getAdminModule === 'function' ? getAdminModule() : null);
|
||||
|
||||
function onPanelActivated(tab) {
|
||||
// Appearance keeps its existing transparent preview behavior.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncSettingsPeek(_modal(), tab === 'appearance');
|
||||
|
||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||
if (tab === 'ai' && typeof refreshAiModelEndpoints === 'function') {
|
||||
refreshAiModelEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
function openAdminTab(tab) {
|
||||
const admin = _admin();
|
||||
if (admin && typeof admin.open === 'function') {
|
||||
admin.open(tab);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncAdminVisibility() {
|
||||
const modalEl = _modal();
|
||||
if (!modalEl) return;
|
||||
const admin = typeof isAdmin === 'function' ? !!isAdmin() : false;
|
||||
modalEl.querySelectorAll('.admin-only').forEach(el => {
|
||||
el.style.display = admin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function open(tab) {
|
||||
if (typeof ensureInitialized === 'function') ensureInitialized();
|
||||
|
||||
if (typeof syncAppearanceCheckboxes === 'function') syncAppearanceCheckboxes();
|
||||
|
||||
const modalEl = _modal();
|
||||
showSettingsModal(modalEl);
|
||||
syncAdminVisibility();
|
||||
|
||||
if (tab) {
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
}
|
||||
|
||||
// Preserve existing panel-specific side effects when Settings is opened
|
||||
// directly to a tab as well as when the user navigates there.
|
||||
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||
onPanelActivated(activeTab);
|
||||
|
||||
// Auto-init admin data if showing an admin tab.
|
||||
const admin = _admin();
|
||||
if (isAdminManagedSettingsTab(activeTab) && admin && !admin._initialized) {
|
||||
admin._initData();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
const modalEl = _modal();
|
||||
if (!modalEl) return;
|
||||
|
||||
// Always clear the Appearance state so the rest of the app does not remain
|
||||
// dimmed if Settings is closed while that panel is active.
|
||||
document.body.classList.remove('settings-appearance-open');
|
||||
syncSettingsPeek(modalEl, false);
|
||||
|
||||
hideSettingsModal(modalEl);
|
||||
}
|
||||
|
||||
return { open, close, onPanelActivated, openAdminTab, syncAdminVisibility };
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Text-to-speech and speech-to-text settings panels.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initTtsSettings() {
|
||||
var provSel = el('set-ttsProviderSelect');
|
||||
var modelSelect = el('set-ttsModelSelect');
|
||||
var modelInput = el('set-ttsModelInput');
|
||||
var voiceSelect = el('set-ttsVoiceSelect');
|
||||
var voiceInput = el('set-ttsVoiceInput');
|
||||
var modelRow = el('set-ttsModelRow');
|
||||
var voiceRow = el('set-ttsVoiceRow');
|
||||
var speedSelect = el('set-ttsSpeedSelect');
|
||||
var speedRow = el('set-ttsSpeedRow');
|
||||
var ttsMsg = el('set-ttsSettingsMsg');
|
||||
var ttsEnabledToggle = el('set-ttsEnabledToggle');
|
||||
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
|
||||
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
|
||||
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
|
||||
} else {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
var ttsKeywords = ['tts', 'audio'];
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
|
||||
if (!hasTTS) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
|
||||
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.tts_provider) provSel.value = settings.tts_provider;
|
||||
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
|
||||
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
|
||||
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load TTS settings', e); }
|
||||
|
||||
function syncTtsDisabled() {
|
||||
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
|
||||
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncTtsDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveTTS() {
|
||||
try {
|
||||
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
|
||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
async function saveAndClearCache() {
|
||||
await saveTTS();
|
||||
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() {
|
||||
var prov = provSel.value;
|
||||
if (prov === 'local') voiceInput.value = 'af_heart';
|
||||
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
|
||||
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
|
||||
updateVisibility();
|
||||
saveTTS();
|
||||
});
|
||||
modelSelect.addEventListener('change', saveAndClearCache);
|
||||
modelInput.addEventListener('change', saveTTS);
|
||||
voiceSelect.addEventListener('change', saveAndClearCache);
|
||||
voiceInput.addEventListener('change', saveTTS);
|
||||
speedSelect.addEventListener('change', saveAndClearCache);
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
|
||||
|
||||
// Preview / test button
|
||||
var previewBtn = el('set-ttsPreviewBtn');
|
||||
if (previewBtn) {
|
||||
var previewAudio = null;
|
||||
var previewPlaying = false;
|
||||
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
|
||||
|
||||
previewBtn.addEventListener('click', async function() {
|
||||
if (previewPlaying) {
|
||||
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
|
||||
window.speechSynthesis.cancel();
|
||||
resetPreview(); return;
|
||||
}
|
||||
var prov = provSel.value;
|
||||
if (prov === 'disabled') {
|
||||
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
|
||||
}
|
||||
var testText = 'Hello, this is a test of text to speech.';
|
||||
previewPlaying = true; previewBtn.textContent = 'Loading...';
|
||||
try {
|
||||
if (prov === 'browser') {
|
||||
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
|
||||
var utt = new SpeechSynthesisUtterance(testText);
|
||||
var voiceVal = getVoice();
|
||||
if (voiceVal) {
|
||||
var voices = window.speechSynthesis.getVoices();
|
||||
var target = voiceVal.toLowerCase();
|
||||
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
|
||||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
|
||||
if (match) utt.voice = match;
|
||||
}
|
||||
utt.rate = parseFloat(speedSelect.value) || 1;
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
utt.onend = resolve;
|
||||
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
});
|
||||
} else {
|
||||
var res = await fetch('/api/tts/synthesize', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText, format: 'audio' })
|
||||
});
|
||||
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
|
||||
var blob = await res.blob();
|
||||
var url = URL.createObjectURL(blob);
|
||||
previewAudio = new Audio(url);
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
|
||||
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
|
||||
previewAudio.play().catch(reject);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
|
||||
} finally {
|
||||
resetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function initSttSettings() {
|
||||
var provSel = el('set-sttProviderSelect');
|
||||
var modelSelect = el('set-sttModelSelect');
|
||||
var modelInput = el('set-sttModelInput');
|
||||
var modelRow = el('set-sttModelRow');
|
||||
var langRow = el('set-sttLangRow');
|
||||
var langInput = el('set-sttLangInput');
|
||||
var sttMsg = el('set-sttSettingsMsg');
|
||||
var sttEnabledToggle = el('set-sttEnabledToggle');
|
||||
var sttConfigWrap = el('set-sttConfigWrap');
|
||||
// STT was removed from AI Defaults — bail if the UI isn't present.
|
||||
if (!provSel) return;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
var showModel = prov === 'local' || prov.startsWith('endpoint:');
|
||||
var showLang = prov !== 'disabled';
|
||||
modelRow.style.display = showModel ? 'flex' : 'none';
|
||||
langRow.style.display = showLang ? 'flex' : 'none';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
} else {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function syncSttDisabled() {
|
||||
var off = sttEnabledToggle && !sttEnabledToggle.checked;
|
||||
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
|
||||
// Effective provider: if toggle is off, treat as disabled regardless of provider select
|
||||
function effectiveProvider() {
|
||||
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
|
||||
return provSel.value;
|
||||
}
|
||||
|
||||
// Add API endpoints that might support STT
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.stt_provider) provSel.value = settings.stt_provider;
|
||||
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
|
||||
if (settings.stt_language) langInput.value = settings.stt_language;
|
||||
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load STT settings', e); }
|
||||
|
||||
syncSttDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveSTT() {
|
||||
try {
|
||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
|
||||
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
||||
// Notify voiceRecorder of effective provider and update send button icon
|
||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
|
||||
modelSelect.addEventListener('change', saveSTT);
|
||||
modelInput.addEventListener('change', saveSTT);
|
||||
langInput.addEventListener('change', saveSTT);
|
||||
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Document writing-style settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initDocumentWritingStyle() {
|
||||
const styleEl = el('set-document-style');
|
||||
const saveBtn = el('set-document-style-save');
|
||||
const extractBtn = el('set-document-style-extract');
|
||||
const fileEl = el('set-document-style-file');
|
||||
const msg = el('set-document-style-msg');
|
||||
if (!styleEl || !saveBtn) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
styleEl.value = String(data.document_writing_style || '');
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to load';
|
||||
}
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const res = await _postSettings({ document_writing_style: styleEl.value });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
extractBtn?.addEventListener('click', () => fileEl?.click());
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
const file = fileEl.files?.[0];
|
||||
if (!file) return;
|
||||
extractBtn.disabled = true;
|
||||
let whirlpool = null;
|
||||
if (msg) {
|
||||
msg.replaceChildren();
|
||||
try {
|
||||
const spinner = window.spinnerModule || (await import('../spinner.js')).default;
|
||||
whirlpool = spinner.createWhirlpool(14);
|
||||
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Analyzing...';
|
||||
msg.append(whirlpool.element, label);
|
||||
} catch (_) {
|
||||
msg.textContent = 'Analyzing...';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch('/api/auth/settings/document-style/extract', {
|
||||
method: 'POST', credentials: 'same-origin', body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
|
||||
styleEl.value = String(data.style);
|
||||
if (msg) msg.textContent = '✓ Extracted — review and save';
|
||||
} catch (error) {
|
||||
if (msg) msg.textContent = error.message || 'Extraction failed';
|
||||
} finally {
|
||||
whirlpool?.destroy?.();
|
||||
extractBtn.disabled = false;
|
||||
fileEl.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -316,7 +316,7 @@ class SmoothPad {
|
||||
}
|
||||
|
||||
function _modal(innerHtml) {
|
||||
// Match the app's standard .modal pattern (defined in static/style.css).
|
||||
// Match the app's standard .modal pattern from the main stylesheet cascade.
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal sig-modal-overlay';
|
||||
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
|
||||
|
||||
+4
-4
@@ -106,7 +106,7 @@
|
||||
<style>
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
||||
/* Mirror the main app's :root defaults from the stylesheet cascade so an
|
||||
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
||||
localStorage — renders the login page in the same palette as the rest
|
||||
of the app instead of a different greys+offwhite scheme. */
|
||||
@@ -115,7 +115,7 @@
|
||||
--red: #e06c75; --green: #50fa7b;
|
||||
--bg-effect-intensity: 1;
|
||||
}
|
||||
/* Background patterns — mirrors static/style.css "Background Patterns"
|
||||
/* Background patterns — mirrors the main app "Background Patterns" styles
|
||||
block so the theme's chosen bgPattern renders on login too. The
|
||||
canvas-based effects (rain, constellations, perlin-flow, petals,
|
||||
sparkles, embers) are spawned by the deferred theme.js import below;
|
||||
@@ -157,7 +157,7 @@
|
||||
font-size: 2rem; font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
/* Same gradient as the main app's welcome-screen "Odysseus" title
|
||||
(static/style.css #welcome-screen .welcome-name) so the brand
|
||||
(the main cascade's #welcome-screen .welcome-name) so the brand
|
||||
wordmark is consistent across login + first-launch screens. */
|
||||
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
|
||||
-webkit-background-clip: text;
|
||||
@@ -175,7 +175,7 @@
|
||||
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
|
||||
page when a field is focused (it auto-zooms any focused input under 16px).
|
||||
This page has its own inline styles, so it doesn't inherit the main app's
|
||||
equivalent rule in static/style.css; mirror it here. !important also lifts
|
||||
equivalent rule in the main stylesheet cascade; mirror it here. !important also lifts
|
||||
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
input:not(.remember-check) { font-size: 16px !important; }
|
||||
|
||||
-47512
File diff suppressed because it is too large
Load Diff
+34
-7
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v654-css-split-3';
|
||||
const CACHE_NAME = 'odysseus-v655-email-library-split';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -39,11 +39,28 @@ const KATEX_FONTS = [
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css?v=20260921chatgptusage2',
|
||||
'/static/css/documents-gallery-editor.css?v=20260923csssplit1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3',
|
||||
'/static/app.js?v=20260921chatgptusage2',
|
||||
'/static/css/00-tokens.css?v=20260929csssplit',
|
||||
'/static/css/01-agent-chat.css?v=20260929csssplit',
|
||||
'/static/css/02-compare.css?v=20260929csssplit',
|
||||
'/static/css/03-agent-chat.css?v=20260929csssplit',
|
||||
'/static/css/04-memory.css?v=20260929csssplit',
|
||||
'/static/css/05-documents.css?v=20260929csssplit',
|
||||
'/static/css/06-admin-settings.css?v=20260929csssplit',
|
||||
'/static/css/07-documents.css?v=20260929csssplit',
|
||||
'/static/css/08-skills.css?v=20260929csssplit',
|
||||
'/static/css/09-gallery.css?v=20260929csssplit',
|
||||
'/static/css/10-cookbook.css?v=20260929csssplit',
|
||||
'/static/css/11-tasks.css?v=20260929csssplit',
|
||||
'/static/css/12-gallery.css?v=20260929csssplit',
|
||||
'/static/css/13-image-editor.css?v=20260929csssplit',
|
||||
'/static/css/14-email.css?v=20260929csssplit',
|
||||
'/static/css/15-notes.css?v=20260929csssplit',
|
||||
'/static/css/16-calendar.css?v=20260929csssplit',
|
||||
'/static/css/17-research.css?v=20260929csssplit',
|
||||
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
|
||||
'/static/app.js?v=20260929csssplit',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
'/static/js/ui.js?v=20260916largetoolscroll1',
|
||||
@@ -78,15 +95,25 @@ const PRECACHE = [
|
||||
'/static/js/theme.js?v=20260911organsrain1',
|
||||
'/static/js/censor.js',
|
||||
'/static/js/settings.js?v=20260912writingstyle3',
|
||||
'/static/js/admin.js?v=20260921chatgptusage2',
|
||||
'/static/js/admin.js?v=20260929csssplit',
|
||||
'/static/js/chatgptSubscriptionUsage.js',
|
||||
'/static/js/init.js?v=20260829chatstyle12',
|
||||
'/static/js/slashCommands.js?v=20260921chatgptusage1',
|
||||
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
|
||||
'/static/js/emailInbox.js?v=20260914aireply4',
|
||||
'/static/js/emailLibrary/index.js',
|
||||
'/static/js/emailLibrary/aiReply.js',
|
||||
'/static/js/emailLibrary/attachments.js',
|
||||
'/static/js/emailLibrary/bodyRender.js',
|
||||
'/static/js/emailLibrary/menus.js',
|
||||
'/static/js/emailLibrary/reader.js',
|
||||
'/static/js/emailLibrary/settingsPage.js',
|
||||
'/static/js/emailLibrary/unsubscribe.js',
|
||||
'/static/js/emailLibrary/utils.js',
|
||||
'/static/js/emailLibrary/signatureFold.js',
|
||||
'/static/js/emailLibrary/state.js',
|
||||
// emailInbox.js has always imported this one; it was never precached.
|
||||
'/static/js/emailLibrary/replyRecipients.js',
|
||||
'/static/js/notes.js?v=20260911notesselectioncancel1',
|
||||
'/static/js/tasks.js?v=20260914taskmodel1',
|
||||
'/static/js/calendar.js?v=20260914emailsource11',
|
||||
|
||||
+29
-5
@@ -142,7 +142,7 @@ swallowed.
|
||||
## CSS computed-style snapshot
|
||||
|
||||
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
|
||||
`static/style.css` - one 51k-line file whose behavior depends on source order -
|
||||
the shipped ordered stylesheet cascade, whose behavior depends on source order,
|
||||
by hashing `getComputedStyle` over a fixed element inventory across pages,
|
||||
viewports, themes and density modes. Any PR that moves CSS has to produce an
|
||||
identical digest or explain why it did not.
|
||||
@@ -158,6 +158,31 @@ The inventory, the baseline and the capture live in `tests/css_snapshot/`;
|
||||
not, and how to find the property that moved when it fails. The run takes about
|
||||
21 seconds and skips when `npm ci` has not been run.
|
||||
|
||||
## Release smoke suite
|
||||
|
||||
`tests/smoke/` drives every advertised feature area once, end to end,
|
||||
against a real instance - the safety net the unit suite does not provide
|
||||
for a route move or a module split. One command boots the worktree and
|
||||
runs it:
|
||||
|
||||
```bash
|
||||
scripts/odysseus-smoke # boot, run every area, stop again
|
||||
scripts/odysseus-smoke --keep-up # leave the instance running
|
||||
scripts/odysseus-smoke --areas # the coverage table, without booting
|
||||
```
|
||||
|
||||
It reads its target instance out of the environment (`APP_PORT` through
|
||||
`internal_api_base()`, plus the dev admin account), so under a plain
|
||||
`pytest` with nothing booted every scenario skips with the reason and
|
||||
the full suite stays green. Models are served by a deterministic
|
||||
loopback stub, never a live endpoint; email uses the repo's existing
|
||||
`ODYSSEUS_EMAIL_FIXTURE` path.
|
||||
|
||||
The report is a per-area table that also prints the areas the suite
|
||||
deliberately does not cover, so it cannot be read as coverage of
|
||||
everything it omits. `tests/smoke/README.md` documents what is in each
|
||||
list and why.
|
||||
|
||||
## Core principles
|
||||
|
||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||
@@ -180,14 +205,13 @@ Use when a test asserts on a CSS rule.
|
||||
|
||||
- Returns every app stylesheet concatenated in the order `static/index.html`
|
||||
loads them, which is the order the cascade actually has.
|
||||
- Panel styles no longer all live in `static/style.css`; reading that file
|
||||
alone ties the test to whichever file a rule sits in today, so it goes red
|
||||
- App styles live across an ordered cascade; reading one fragment alone ties
|
||||
the test to whichever file a rule sits in today, so it goes red
|
||||
when a rule moves without the rendered page changing.
|
||||
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
|
||||
files or the request URLs rather than their contents.
|
||||
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
|
||||
driven through Playwright, so it gets the whole cascade instead of only
|
||||
`style.css`.
|
||||
driven through Playwright, so it gets the whole shipped cascade.
|
||||
- All of them fail loudly if `index.html` links a stylesheet that is missing.
|
||||
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Computed-style snapshot harness
|
||||
|
||||
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
|
||||
The app CSS is an ordered multi-file cascade. Hundreds of selectors are
|
||||
declared more than once and `!important` appears throughout, so the rendered
|
||||
result is a function of **source order**. Extracting a block into its own file,
|
||||
reordering `<link>` tags, or moving an `@media` rule can silently change which
|
||||
@@ -29,7 +29,7 @@ so the measurements stay independent.
|
||||
|
||||
The **bench** page measures one synthesised element per selector, built from
|
||||
the selector itself. Its selector list is evidence-driven: every selector
|
||||
declared **more than once** in `style.css` that can be expressed as a static
|
||||
declared **more than once** in the app cascade that can be expressed as a static
|
||||
compound chain (551 of them), plus a curated set covering chat, documents,
|
||||
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
|
||||
are the ones a reorder can actually flip, so they are the ones worth benching.
|
||||
|
||||
@@ -9,7 +9,27 @@
|
||||
for opening the page by hand; it is deliberately unversioned so the
|
||||
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
|
||||
pins the shipped ones). -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
|
||||
|
||||
@@ -55,7 +55,10 @@ function swapRuleOccurrences(css, selector) {
|
||||
}
|
||||
const matches = blocks.filter(b => b.selector === selector && b.end !== undefined);
|
||||
if (matches.length < 2) {
|
||||
throw new Error(`swap-rule: need two top-level blocks for "${selector}", found ${matches.length}`);
|
||||
// The selector is not in this sheet, or appears once. The stylesheet is
|
||||
// split across several files, so that is expected for most of them: the
|
||||
// caller decides whether any sheet matched at all.
|
||||
return null;
|
||||
}
|
||||
const [a, b] = matches;
|
||||
const textA = css.slice(a.start, a.end);
|
||||
@@ -186,6 +189,7 @@ async function main() {
|
||||
const missing = {};
|
||||
|
||||
try {
|
||||
let swapped = 0;
|
||||
for (const page of job.pages) {
|
||||
snapshot[page.name] = {};
|
||||
let shippedStylesheets = null;
|
||||
@@ -217,9 +221,15 @@ async function main() {
|
||||
});
|
||||
|
||||
if (job.swapRule) {
|
||||
await tab.route('**/static/style.css*', async route => {
|
||||
// The cascade is spread over several files, so find the one that
|
||||
// actually holds two top-level blocks of the selector and rewrite
|
||||
// only that one. Every other sheet passes through untouched.
|
||||
await tab.route('**/static/**/*.css*', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = swapRuleOccurrences(await response.text(), job.swapRule);
|
||||
const original = await response.text();
|
||||
const body = swapRuleOccurrences(original, job.swapRule);
|
||||
if (body === null) return route.fulfill({ response, body: original });
|
||||
swapped += 1;
|
||||
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
|
||||
});
|
||||
}
|
||||
@@ -253,6 +263,9 @@ async function main() {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
if (job.swapRule && swapped === 0) {
|
||||
throw new Error(`swap-rule: no stylesheet had two top-level blocks for "${job.swapRule}"`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function addAppStyles(page) {
|
||||
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
|
||||
for (const url of await stylesheetUrls()) {
|
||||
await page.addStyleTag({ url });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/login');
|
||||
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
|
||||
await addAppStyles(page);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const { default: state } = await import('/static/js/compare/state.js');
|
||||
@@ -58,7 +65,7 @@ test('mobile compare uses tabs to show one mounted pane at a time', async ({ pag
|
||||
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/login');
|
||||
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
|
||||
await addAppStyles(page);
|
||||
await page.evaluate(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="compare-probe-overlay">
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Read the document editor's JavaScript the way the browser loads it.
|
||||
|
||||
``static/js/document.js`` is being decomposed. It stays the entry point the
|
||||
browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches
|
||||
it, and five modules import it -- but the implementation moves into modules
|
||||
under ``static/js/document/``. The implementation set is the entry plus that
|
||||
directory.
|
||||
|
||||
Two habits in the existing tests do not survive that move, and this module
|
||||
exists to replace both.
|
||||
|
||||
**Reading the entry file alone.** A membership assertion against
|
||||
``document.js`` silently covers less the moment the behaviour it names moves
|
||||
out. Use :func:`document_source` for those: it is the whole implementation set,
|
||||
so a test keeps finding what it asserts on wherever the code lands.
|
||||
|
||||
**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only
|
||||
the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file.
|
||||
After a split they may sit in different modules, and then the slice runs to the
|
||||
end of the concatenation and quietly grows: an ``assert "x" in region`` passes
|
||||
against code it was never meant to see. Several of these also hard-code the
|
||||
entry file's two-space indentation (``"\\n function showDocTabMenu"``), which
|
||||
no extracted module reproduces. Use :func:`function_body` or
|
||||
:func:`declaration` instead -- they find the construct by name, in whichever
|
||||
module defines it, and end at its real closing brace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||
_ENTRY = _STATIC / "js" / "document.js"
|
||||
|
||||
# Extracted implementation modules get one home, so the set is discoverable
|
||||
# without a manifest anyone has to remember to update.
|
||||
_IMPL_DIR = _STATIC / "js" / "document"
|
||||
|
||||
|
||||
def document_source_paths() -> list[Path]:
|
||||
"""Every file holding document-editor implementation, entry first.
|
||||
|
||||
The entry comes first so a concatenation reads in the order the browser
|
||||
evaluates the graph's root; the rest are sorted for determinism.
|
||||
"""
|
||||
if not _ENTRY.is_file():
|
||||
raise AssertionError(f"document editor entry point is missing: {_ENTRY}")
|
||||
extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else []
|
||||
return [_ENTRY, *extracted]
|
||||
|
||||
|
||||
def document_source() -> str:
|
||||
"""The whole implementation set as one string, entry first.
|
||||
|
||||
For membership assertions (``assert "..." in document_source()``). For
|
||||
anything positional use :func:`function_body` or :func:`declaration`.
|
||||
"""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths())
|
||||
|
||||
|
||||
# --- Locating a construct by name, not by what follows it ------------------
|
||||
|
||||
def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]:
|
||||
"""The source text that defines ``what``, and the offset of the match."""
|
||||
hits = []
|
||||
for path in document_source_paths():
|
||||
src = path.read_text(encoding="utf-8")
|
||||
for m in pattern.finditer(src):
|
||||
hits.append((path, src, m.start()))
|
||||
if not hits:
|
||||
raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}")
|
||||
if len(hits) > 1:
|
||||
where = ", ".join(
|
||||
f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}"
|
||||
for p, s, o in hits
|
||||
)
|
||||
raise AssertionError(f"{what} is defined more than once ({where})")
|
||||
_path, src, offset = hits[0]
|
||||
return src, offset
|
||||
|
||||
|
||||
def _describe_set() -> str:
|
||||
return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths())
|
||||
|
||||
|
||||
def function_body(name: str) -> str:
|
||||
"""The full text of function ``name``, signature through closing brace.
|
||||
|
||||
Matches ``function name``, optionally prefixed by ``export`` and/or
|
||||
``async``, at any indentation, in whichever module of the implementation
|
||||
set defines it. The end is found by matching braces rather than by naming
|
||||
whatever declaration follows, so moving the function -- or the one after
|
||||
it -- does not change the region a test sees.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||
re.M,
|
||||
)
|
||||
src, offset = _defining_source(pattern, f"function {name}")
|
||||
# Skip the parameter list before looking for the body. A destructured
|
||||
# parameter -- `function f(table, { headerRow, headerColumn })` -- opens a
|
||||
# brace that is not the body, and matching it would return the signature
|
||||
# alone.
|
||||
body_start = _end_of_params(src, src.index("(", offset))
|
||||
return src[offset : _end_of_block(src, body_start)]
|
||||
|
||||
|
||||
def declaration(name: str) -> str:
|
||||
"""The full text of a top-level ``const``/``let``/``var`` named ``name``.
|
||||
|
||||
For the array and object tables the tests assert on (toolbar groups, slash
|
||||
commands, input rules). Ends at the declaration's closing bracket or brace,
|
||||
or at the end of the statement for a simple initialiser.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b",
|
||||
re.M,
|
||||
)
|
||||
src, offset = _defining_source(pattern, f"declaration {name}")
|
||||
return src[offset : _end_of_statement(src, offset)]
|
||||
|
||||
|
||||
# --- A brace matcher that is not fooled by braces inside literals ----------
|
||||
#
|
||||
# `document.js` is full of template literals building DOM, regexes containing
|
||||
# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on
|
||||
# all three, so the scan tracks what kind of text it is inside.
|
||||
|
||||
# After one of these, `/` starts a regex literal; after a value it is division.
|
||||
_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$")
|
||||
|
||||
|
||||
def _scan(src: str, start: int, stop):
|
||||
"""Walk ``src`` from ``start``, skipping literals and comments.
|
||||
|
||||
Calls ``stop(index, depth_delta_applied)``-free: instead it yields
|
||||
``(index, char)`` for code positions only, so callers can track nesting.
|
||||
"""
|
||||
i, n = start, len(src)
|
||||
# Stack of template-literal depths: entering `${` pushes brace depth.
|
||||
template_stack: list[int] = []
|
||||
while i < n:
|
||||
c = src[i]
|
||||
two = src[i : i + 2]
|
||||
if two == "//":
|
||||
j = src.find("\n", i)
|
||||
i = n if j == -1 else j + 1
|
||||
continue
|
||||
if two == "/*":
|
||||
j = src.find("*/", i + 2)
|
||||
i = n if j == -1 else j + 2
|
||||
continue
|
||||
if c in "'\"":
|
||||
i = _skip_quoted(src, i, c)
|
||||
continue
|
||||
if c == "`":
|
||||
i += 1
|
||||
i, entered = _skip_template(src, i)
|
||||
if entered:
|
||||
template_stack.append(0)
|
||||
continue
|
||||
if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]):
|
||||
j = _skip_regex(src, i)
|
||||
if j is not None:
|
||||
i = j
|
||||
continue
|
||||
if template_stack:
|
||||
# Inside `${ ... }`: a `}` that closes it returns to template text.
|
||||
if c == "{":
|
||||
template_stack[-1] += 1
|
||||
elif c == "}":
|
||||
if template_stack[-1] == 0:
|
||||
template_stack.pop()
|
||||
i += 1
|
||||
i, entered = _skip_template(src, i)
|
||||
if entered:
|
||||
template_stack.append(0)
|
||||
continue
|
||||
template_stack[-1] -= 1
|
||||
yield i, c
|
||||
i += 1
|
||||
|
||||
|
||||
def _skip_quoted(src: str, i: int, quote: str) -> int:
|
||||
i += 1
|
||||
n = len(src)
|
||||
while i < n:
|
||||
if src[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if src[i] == quote:
|
||||
return i + 1
|
||||
if src[i] == "\n": # unterminated; do not run away
|
||||
return i
|
||||
i += 1
|
||||
return n
|
||||
|
||||
|
||||
def _skip_template(src: str, i: int) -> tuple[int, bool]:
|
||||
"""From inside template text, advance to the backtick end or a ``${``.
|
||||
|
||||
Returns the new index and whether an interpolation was entered.
|
||||
"""
|
||||
n = len(src)
|
||||
while i < n:
|
||||
if src[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if src[i] == "`":
|
||||
return i + 1, False
|
||||
if src[i : i + 2] == "${":
|
||||
return i + 2, True
|
||||
i += 1
|
||||
return n, False
|
||||
|
||||
|
||||
def _skip_regex(src: str, i: int) -> int | None:
|
||||
"""Past a regex literal starting at ``i``, or None if it is not one."""
|
||||
i += 1
|
||||
n = len(src)
|
||||
in_class = False
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == "\n":
|
||||
return None
|
||||
if in_class:
|
||||
if c == "]":
|
||||
in_class = False
|
||||
elif c == "[":
|
||||
in_class = True
|
||||
elif c == "/":
|
||||
i += 1
|
||||
while i < n and src[i].isalpha(): # flags
|
||||
i += 1
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _end_of_block(src: str, start: int) -> int:
|
||||
"""Index just past the ``}`` closing the first ``{`` at or after ``start``."""
|
||||
depth = 0
|
||||
seen = False
|
||||
for i, c in _scan(src, start, None):
|
||||
if c == "{":
|
||||
depth += 1
|
||||
seen = True
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if seen and depth == 0:
|
||||
return i + 1
|
||||
raise AssertionError(f"unbalanced braces from offset {start}")
|
||||
|
||||
|
||||
def _end_of_statement(src: str, start: int) -> int:
|
||||
"""Index just past the end of the declaration statement at ``start``.
|
||||
|
||||
Ends on the ``;`` or newline that closes it at nesting depth zero, so an
|
||||
array or object initialiser is returned whole.
|
||||
"""
|
||||
depth = 0
|
||||
for i, c in _scan(src, start, None):
|
||||
if c in "{[(":
|
||||
depth += 1
|
||||
elif c in "}])":
|
||||
depth -= 1
|
||||
elif depth == 0 and c == ";":
|
||||
return i + 1
|
||||
elif depth == 0 and c == "\n" and i > start:
|
||||
return i
|
||||
return len(src)
|
||||
|
||||
|
||||
def _end_of_params(src: str, open_paren: int) -> int:
|
||||
"""Index just past the ``)`` closing the parameter list at ``open_paren``."""
|
||||
depth = 0
|
||||
for i, c in _scan(src, open_paren, None):
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i + 1
|
||||
raise AssertionError(f"unbalanced parameter list at offset {open_paren}")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Read a split JS subpackage the way the module graph does.
|
||||
|
||||
``static/js/emailLibrary.js`` is a re-export wrapper; the implementation lives
|
||||
in ``static/js/emailLibrary/``. A test that asserts on email-library behaviour
|
||||
has to look at every module in that package, because reading one file ties the
|
||||
test to whichever module a function happens to sit in today — it goes red the
|
||||
next time something moves without any behaviour changing.
|
||||
|
||||
That is the mistake the stylesheet split made, which is why
|
||||
``tests/helpers/stylesheets.py`` exists. This is the same helper for JS.
|
||||
|
||||
Order is deterministic: the entry module first, then the rest alphabetically.
|
||||
Tests that assert "A appears before B" are asserting about one module's source,
|
||||
not about the package, so the concatenation order only has to be stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC_JS = Path(__file__).resolve().parents[2] / "static" / "js"
|
||||
|
||||
EMAIL_LIBRARY_WRAPPER = _STATIC_JS / "emailLibrary.js"
|
||||
EMAIL_LIBRARY_PACKAGE = _STATIC_JS / "emailLibrary"
|
||||
EMAIL_LIBRARY_ENTRY = EMAIL_LIBRARY_PACKAGE / "index.js"
|
||||
|
||||
|
||||
def _package_paths(package: Path, entry: Path) -> list[Path]:
|
||||
if not entry.is_file():
|
||||
raise AssertionError(f"missing package entry module: {entry}")
|
||||
rest = sorted(p for p in package.glob("*.js") if p != entry)
|
||||
return [entry, *rest]
|
||||
|
||||
|
||||
def email_library_paths(include_wrapper: bool = False) -> list[Path]:
|
||||
"""Every module of the email-library package, entry module first.
|
||||
|
||||
``include_wrapper`` adds the compatibility file at the old top-level path.
|
||||
Leave it off for assertions about implementation code: the wrapper holds
|
||||
only an ``export … from`` list.
|
||||
"""
|
||||
paths = _package_paths(EMAIL_LIBRARY_PACKAGE, EMAIL_LIBRARY_ENTRY)
|
||||
return [EMAIL_LIBRARY_WRAPPER, *paths] if include_wrapper else paths
|
||||
|
||||
|
||||
def email_library_source(include_wrapper: bool = False) -> str:
|
||||
"""The whole email-library package as one string."""
|
||||
return "\n".join(
|
||||
p.read_text(encoding="utf-8") for p in email_library_paths(include_wrapper)
|
||||
)
|
||||
|
||||
|
||||
def js_function_source(name: str, source: str | None = None) -> str:
|
||||
"""One top-level JS function, from its signature to its closing brace.
|
||||
|
||||
Two things this does not do, on purpose.
|
||||
|
||||
It does not slice between a signature and a marker further down ("from
|
||||
``_toggleCardPreview`` to the ``Wrap a probable signature`` comment"). That
|
||||
is what a split breaks: the marker ends up in another module, the slice runs
|
||||
past the end of the function without failing, and the assertions keep
|
||||
passing against the wrong text.
|
||||
|
||||
It does not balance braces by walking characters either. The obvious version
|
||||
of that walker treats the apostrophe in a ``// that's a scroll`` comment as
|
||||
an open quote and swallows every brace until the next one, which ends the
|
||||
function early — silently, again.
|
||||
|
||||
Instead it uses the invariant the file actually holds: a top-level
|
||||
declaration starts at column 0, so its closing brace is the next lone ``}``
|
||||
at column 0.
|
||||
"""
|
||||
text = email_library_source() if source is None else source
|
||||
signature = re.compile(
|
||||
r"^(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||
re.M,
|
||||
)
|
||||
match = signature.search(text)
|
||||
assert match, f"no top-level declaration of {name}"
|
||||
closing = re.compile(r"^\}", re.M).search(text, match.end())
|
||||
assert closing, f"unterminated function {name}"
|
||||
return text[match.start():closing.end()]
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const STATIC = join(HERE, '..', '..', 'static');
|
||||
const INDEX = join(STATIC, 'index.html');
|
||||
|
||||
const LINK = /<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']\/static\/([^"'?]+)([^"']*)["']/gi;
|
||||
|
||||
async function entries() {
|
||||
const html = await readFile(INDEX, 'utf8');
|
||||
const out = [];
|
||||
|
||||
for (const match of html.matchAll(LINK)) {
|
||||
const rel = match[1];
|
||||
const query = match[2];
|
||||
|
||||
if (rel.startsWith('lib/')) continue;
|
||||
|
||||
out.push({
|
||||
path: join(STATIC, rel),
|
||||
url: `/static/${rel}${query}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!out.length) {
|
||||
throw new Error(`no app stylesheet <link> tags found in ${INDEX}`);
|
||||
}
|
||||
|
||||
for (const entry of out) {
|
||||
try {
|
||||
await readFile(entry.path);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`index.html links a stylesheet that does not exist: ${entry.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function stylesheetPaths() {
|
||||
return (await entries()).map(entry => entry.path);
|
||||
}
|
||||
|
||||
export async function stylesheetUrls() {
|
||||
return (await entries()).map(entry => entry.url);
|
||||
}
|
||||
|
||||
export async function stylesheetLinkTags() {
|
||||
return (await stylesheetUrls())
|
||||
.map(url => `<link rel="stylesheet" href="${url}">`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function appCss() {
|
||||
const paths = await stylesheetPaths();
|
||||
const parts = [];
|
||||
|
||||
for (const path of paths) {
|
||||
parts.push(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Read the app's CSS the way the browser does.
|
||||
|
||||
``static/style.css`` no longer holds every rule: panel styles live in separate
|
||||
files that ``static/index.html`` loads eagerly, in a fixed order, right after
|
||||
it. The cascade is the concatenation of those files in that order.
|
||||
The former ``static/style.css`` is now an ordered set of numbered fragments,
|
||||
followed by the existing panel stylesheets. ``static/index.html`` loads the
|
||||
complete cascade eagerly in the order the browser must apply it.
|
||||
|
||||
A test that asserts on a rule must therefore look at all of them. Reading
|
||||
``static/style.css`` alone ties the test to whichever file a rule happens to
|
||||
A test that asserts on a rule must therefore look at the complete cascade.
|
||||
Reading one fragment alone ties the test to whichever file a rule happens to
|
||||
sit in today, so it goes red the next time a rule moves without anything about
|
||||
the rendered page having changed.
|
||||
"""
|
||||
@@ -53,11 +53,32 @@ def stylesheet_urls() -> list[str]:
|
||||
|
||||
|
||||
def stylesheet_link_tags() -> str:
|
||||
"""The <link> tags to drop into a synthetic page so it gets the whole
|
||||
cascade, not just style.css."""
|
||||
"""The <link> tags for a synthetic page that needs the whole cascade."""
|
||||
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
|
||||
|
||||
|
||||
def app_css() -> str:
|
||||
"""The whole cascade as one string, in load order."""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in stylesheet_paths())
|
||||
|
||||
|
||||
def stylesheet_cache_version() -> str:
|
||||
"""The single ``?v=`` token every app stylesheet link carries.
|
||||
|
||||
The stylesheet is split across several files that must be busted together:
|
||||
shipping one fragment under a stale token serves a browser half of an old
|
||||
cascade and half of a new one. Tests ask for the shared version here instead of deriving it from one
|
||||
stylesheet filename, so they keep checking the invariant
|
||||
rather than a filename.
|
||||
"""
|
||||
versions = set()
|
||||
for url in stylesheet_urls():
|
||||
m = re.search(r"\?v=([^&]+)$", url)
|
||||
if not m:
|
||||
raise AssertionError(f"app stylesheet has no cache-bust token: {url}")
|
||||
versions.add(m.group(1))
|
||||
if len(versions) != 1:
|
||||
raise AssertionError(
|
||||
f"app stylesheets disagree on their cache-bust token: {sorted(versions)}"
|
||||
)
|
||||
return versions.pop()
|
||||
|
||||
@@ -20,6 +20,14 @@ const REAL_MODULES = new Set([
|
||||
path.join(JS, 'settings/sidebar.js'),
|
||||
path.join(JS, 'settings/navigation.js'),
|
||||
path.join(JS, 'settings/lifecycle.js'),
|
||||
path.join(JS, 'settings/api.js'),
|
||||
path.join(JS, 'settings/speech.js'),
|
||||
path.join(JS, 'settings/writingStyle.js'),
|
||||
path.join(JS, 'settings/imageModels.js'),
|
||||
path.join(JS, 'settings/agent.js'),
|
||||
path.join(JS, 'settings/shell.js'),
|
||||
path.join(JS, 'settings/peek.js'),
|
||||
path.join(JS, 'settings/oauthReturn.js'),
|
||||
path.join(JS, 'searchProviderIcons.js'),
|
||||
]);
|
||||
|
||||
@@ -497,6 +505,11 @@ function buildFixture(document) {
|
||||
header.className = 'modal-header';
|
||||
modal.appendChild(header);
|
||||
|
||||
const peekToggle = document.createElement('button');
|
||||
peekToggle.id = 'settings-opacity-wrap';
|
||||
peekToggle.className = 'theme-opacity-wrap theme-opacity-toggle hidden';
|
||||
header.appendChild(peekToggle);
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.className = 'close-btn';
|
||||
header.appendChild(close);
|
||||
@@ -539,6 +552,14 @@ function buildFixture(document) {
|
||||
panels.className = 'settings-panels';
|
||||
content.appendChild(panels);
|
||||
|
||||
const adminCard = document.createElement('div');
|
||||
adminCard.className = 'admin-card';
|
||||
panels.appendChild(adminCard);
|
||||
|
||||
const adminOnly = document.createElement('div');
|
||||
adminOnly.className = 'admin-only';
|
||||
adminCard.appendChild(adminOnly);
|
||||
|
||||
const panelIds = [
|
||||
'services',
|
||||
'added-models',
|
||||
@@ -590,6 +611,9 @@ function buildFixture(document) {
|
||||
sidebarHandle,
|
||||
searchInput,
|
||||
searchResults,
|
||||
peekToggle,
|
||||
adminCard,
|
||||
adminOnly,
|
||||
services: settingsPanels.services,
|
||||
appearance: settingsPanels.appearance,
|
||||
ai: settingsPanels.ai,
|
||||
@@ -994,6 +1018,14 @@ assert(
|
||||
);
|
||||
|
||||
|
||||
// shell.js owns admin-only visibility. A non-admin must not merely see an
|
||||
// unpopulated admin control — the element has to be hidden on every open().
|
||||
assert(
|
||||
fixture.adminOnly.style.display === 'none',
|
||||
'open() did not hide .admin-only for a non-admin',
|
||||
);
|
||||
|
||||
|
||||
// #6040 coordinator integration: initAll() must bind the real finder and
|
||||
// sidebar controllers, not merely make their modules link successfully.
|
||||
assert(
|
||||
@@ -1050,6 +1082,28 @@ assert(
|
||||
'navigation callback did not apply Appearance coordinator state',
|
||||
);
|
||||
|
||||
assert(
|
||||
!fixture.peekToggle.classList.contains('hidden'),
|
||||
'Appearance activation did not reveal the Peek toggle',
|
||||
);
|
||||
|
||||
|
||||
// peek.js fades the window background via color-mix, never element opacity, so
|
||||
// the controls stay readable while the user previews the page behind Settings.
|
||||
fixture.peekToggle.click();
|
||||
|
||||
assert(
|
||||
fixture.content.style.values.background
|
||||
=== 'color-mix(in srgb, var(--bg) 55%, transparent)',
|
||||
'Peek toggle did not fade the Settings window background',
|
||||
);
|
||||
|
||||
assert(
|
||||
fixture.adminCard.style.values.background
|
||||
=== 'color-mix(in srgb, var(--panel) 55%, transparent)',
|
||||
'Peek toggle did not fade the Settings cards',
|
||||
);
|
||||
|
||||
|
||||
// Direct public open() after initialization must still coordinate activation.
|
||||
settings.open('ai');
|
||||
@@ -1069,6 +1123,19 @@ assert(
|
||||
'direct open("ai") did not clear Appearance coordinator state',
|
||||
);
|
||||
|
||||
// Leaving Appearance with Peek still toggled on must not leave the rest of
|
||||
// Settings faded — this is the bug the sync exists to prevent.
|
||||
assert(
|
||||
fixture.content.style.values.background === undefined
|
||||
&& fixture.adminCard.style.values.background === undefined,
|
||||
'leaving Appearance left the Peek fade applied',
|
||||
);
|
||||
|
||||
assert(
|
||||
fixture.peekToggle.classList.contains('hidden'),
|
||||
'leaving Appearance left the Peek toggle visible',
|
||||
);
|
||||
|
||||
|
||||
// Public close() must route through the real lifecycle module.
|
||||
settings.close();
|
||||
@@ -1084,6 +1151,44 @@ assert(
|
||||
);
|
||||
|
||||
|
||||
// shell.js hands an admin-managed tab to admin.js and must not then perform a
|
||||
// second local activation. Nothing before this point installs an admin module,
|
||||
// so the earlier assertions covered the no-admin-module fallback.
|
||||
const adminCalls = [];
|
||||
|
||||
sandbox.adminModule = {
|
||||
open(tab) {
|
||||
adminCalls.push(tab);
|
||||
return true;
|
||||
},
|
||||
_initData() {
|
||||
adminCalls.push('_initData');
|
||||
},
|
||||
};
|
||||
|
||||
fixture.settingsPanels.users.button.click();
|
||||
|
||||
assert(
|
||||
adminCalls.length === 1 && adminCalls[0] === 'users',
|
||||
`admin tab click did not hand "users" to the admin module: ${adminCalls}`,
|
||||
);
|
||||
|
||||
assert(
|
||||
!fixture.settingsPanels.users.button.classList.contains('active'),
|
||||
'shell activated an admin tab locally after the admin module claimed it',
|
||||
);
|
||||
|
||||
|
||||
// Admin status is read per open(), not cached at initialization.
|
||||
sandbox._isAdmin = true;
|
||||
settings.open('services');
|
||||
|
||||
assert(
|
||||
fixture.adminOnly.style.display === '',
|
||||
'open() did not reveal .admin-only for an admin',
|
||||
);
|
||||
|
||||
|
||||
// initAll() starts some existing async panel initializers without awaiting
|
||||
// them. Give already-ready continuations a chance to run before declaring the
|
||||
// smoke successful, so late coordinator/setup exceptions still fail the test.
|
||||
@@ -1098,4 +1203,7 @@ console.log(JSON.stringify({
|
||||
navigationCallback: true,
|
||||
directOpen: true,
|
||||
directClose: true,
|
||||
peekChrome: true,
|
||||
adminVisibility: true,
|
||||
adminTabHandoff: true,
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { appCss } from './helpers/stylesheets.mjs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
test('research primary actions use compact mobile sizing and retain desktop sizing', async () => {
|
||||
const css = await readFile(new URL('../static/style.css', import.meta.url), 'utf8');
|
||||
test('research primary actions retain shipped cascade sizing across mobile and desktop', async () => {
|
||||
const css = await appCss();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
@@ -25,10 +25,13 @@ test('research primary actions use compact mobile sizing and retain desktop sizi
|
||||
}));
|
||||
for (const button of buttons) {
|
||||
if (width <= 600) {
|
||||
assert.equal(button.width, 24);
|
||||
assert.equal(button.height, 22);
|
||||
assert.equal(button.icon, 10);
|
||||
assert.equal(button.labelHidden, true);
|
||||
// Assert the complete shipped cascade, not the historical
|
||||
// style.css-only result. Later app styles keep the action labels
|
||||
// visible and use the larger mobile control geometry.
|
||||
assert.ok(button.width > 24);
|
||||
assert.equal(button.height, 28);
|
||||
assert.equal(button.icon, 13);
|
||||
assert.equal(button.labelHidden, false);
|
||||
} else {
|
||||
assert.equal(button.height, 20);
|
||||
assert.equal(button.icon, 10);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Release smoke suite
|
||||
|
||||
One command that boots this worktree and drives every advertised feature
|
||||
area once, end to end, against a real instance.
|
||||
|
||||
```bash
|
||||
scripts/odysseus-smoke # boot, run every area, stop again
|
||||
scripts/odysseus-smoke --keep-up # leave the instance running afterwards
|
||||
scripts/odysseus-smoke --no-boot # drive whatever is already up here
|
||||
scripts/odysseus-smoke --areas # print the coverage table without booting
|
||||
scripts/odysseus-smoke -- -k notes
|
||||
```
|
||||
|
||||
## Why it exists
|
||||
|
||||
The decomposition work had two safety nets and neither covered the
|
||||
product. The checkpoint benchmark measures the agent runtime. The
|
||||
computed-style snapshot in `tests/test_css_computed_style_snapshot.py`
|
||||
pins the rendered CSS. Nothing checked that Notes, Calendar, Documents,
|
||||
Email, Memory, Cookbook or Settings still worked after a route package
|
||||
moved or a 17,000-line module was split, and the unit suite does not:
|
||||
`StressTestor`'s review of #5898 is the worked proof that a
|
||||
byte-identical file-for-file move can break eleven tests that pass on
|
||||
the base branch, with CI green throughout.
|
||||
|
||||
## Where it lives and why
|
||||
|
||||
pytest, not Playwright. Both are in the repo, so this adds no third
|
||||
harness, and the choice went to pytest because every scenario here is a
|
||||
request/response round trip rather than a rendering assertion -
|
||||
rendering is already covered by the computed-style snapshot, and the
|
||||
28 Playwright specs under `tests/e2e/photo-editor/` are the one area
|
||||
with browser coverage. A browser would have added flake and start-up
|
||||
cost for no extra signal.
|
||||
|
||||
It owns no instance logic. `scripts/odysseus-dev` already derives ports
|
||||
per worktree, keeps the data dir and ChromaDB out of `data/`, and waits
|
||||
on `/api/ready` rather than a TCP accept, so `scripts/odysseus-smoke`
|
||||
boots through it and only adds the scenarios and the report.
|
||||
|
||||
## The contract with the runner
|
||||
|
||||
Four environment values, which are what `odysseus dev env` prints plus
|
||||
the dev admin account:
|
||||
|
||||
| Variable | Read through | Used for |
|
||||
|---|---|---|
|
||||
| `APP_PORT` | `src.constants.internal_api_base()` | which instance to drive |
|
||||
| `ODYSSEUS_ADMIN_USER` | - | who to authenticate as |
|
||||
| `ODYSSEUS_ADMIN_PASSWORD` | - | " |
|
||||
| `ODYSSEUS_DATA_DIR` | `src.constants.DATA_DIR` | where the email fixture file goes |
|
||||
|
||||
Run under a plain `pytest` with none of them set, every scenario skips
|
||||
with the reason and the full suite stays green. `APP_PORT` pointing at
|
||||
one of `odysseus dev`'s reserved ports - a normal launch of this
|
||||
checkout, the machine's own instance - is refused rather than driven,
|
||||
because the scenarios create and delete real records.
|
||||
|
||||
## The deterministic provider
|
||||
|
||||
`stub_provider.py` is an OpenAI-compatible server on an ephemeral
|
||||
loopback port: `GET /v1/models` and `POST /v1/chat/completions`, both
|
||||
buffered and streamed. No scenario touches a live model endpoint or the
|
||||
network. It serves two model ids so the Compare area has something to
|
||||
reveal, and it records every request so a scenario can assert the user's
|
||||
message actually reached the provider rather than only that some text
|
||||
came back.
|
||||
|
||||
Email uses the repo's own deterministic path rather than a second
|
||||
mechanism: `routes/email_routes.py` serves a fixture inbox when
|
||||
`ODYSSEUS_EMAIL_FIXTURE=1` and a fixture file is in the data dir. The
|
||||
suite writes the file and restores whatever was there; the flag is read
|
||||
inside the app's process, which is why the runner owns the boot.
|
||||
|
||||
## What is covered
|
||||
|
||||
One scenario per area, each asserting a user-visible outcome rather than
|
||||
a status code. `scripts/odysseus-smoke --areas` prints the current list.
|
||||
|
||||
| Area | What it asserts |
|
||||
|---|---|
|
||||
| Chat | a turn against the stub comes back rendered, on both the buffered and the streamed path, and is in the session history |
|
||||
| Compare | a blind comparison streams both sides and the vote reveals which model produced which reply |
|
||||
| Notes | a note is listed, read back, edited, and 404s after delete |
|
||||
| Calendar | an event appears in the window the UI queries and is gone after delete |
|
||||
| Tasks | a daily task is accepted with a computed next run, is listed, and pauses |
|
||||
| Documents (editor) | an edit adds a version, both versions read back, and a restore returns the first |
|
||||
| Documents (RAG) | an uploaded file is chunked, indexed and listed |
|
||||
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
|
||||
| Memory | a fact is listed, found by search, and gone after delete |
|
||||
| Uploads | an attachment reads back byte for byte |
|
||||
| Cookbook | hardware is detected and recommendations come back sized against it; state persists |
|
||||
| Settings | a preference written on one session is still there after a new login |
|
||||
|
||||
## What is not covered, and why
|
||||
|
||||
Printed next to the results on every run, so a reader cannot mistake the
|
||||
table for coverage of everything it does not mention. `DECLARED_GAPS` in
|
||||
`areas.py` is the list; the short version:
|
||||
|
||||
- **Deep Research** and **Web Search** need live egress. A deterministic
|
||||
stub for the crawler would be an application change, which this is
|
||||
not.
|
||||
- **Email over IMAP/SMTP** is covered only as far as the fixture path
|
||||
goes. There is no local mail server, so real account sync and send are
|
||||
untested.
|
||||
- **Cookbook download and serve** needs tmux, a GPU runtime and a
|
||||
multi-gigabyte download.
|
||||
- **Gallery and the photo editor** already have the repo's only
|
||||
Playwright specs.
|
||||
- **The agent tool loop** is what the checkpoint benchmark measures.
|
||||
- **MCP servers** are stdio subprocesses outside the app's readiness
|
||||
contract.
|
||||
- **Rendering and layout** are pinned by the computed-style snapshot.
|
||||
|
||||
`Documents (RAG)` is the one covered area that can report `SKIP` on a
|
||||
clean checkout: `requirements.txt` pins `chromadb-client`, the HTTP
|
||||
client, and the ChromaDB *server* is a separate install. Without one
|
||||
reachable, the upload route returns a deliberate 503 and the row reads
|
||||
`SKIP` with that reason. Install `chromadb` in the venv and it goes
|
||||
green.
|
||||
|
||||
## Reading the report
|
||||
|
||||
The table has one row per area in `areas.COVERED`, built from what
|
||||
pytest reported rather than from anything a scenario asserts about
|
||||
itself. An area whose module never ran shows as `NOT RUN`, so deleting
|
||||
or renaming a file cannot make a row disappear -
|
||||
`tests/test_smoke_area_table.py` pins that, and that a module on disk
|
||||
must be registered.
|
||||
|
||||
## What a run leaves behind
|
||||
|
||||
Every scenario deletes what it created, with two exceptions on the
|
||||
scratch instance: the preference key `odysseus_smoke_preference`, which
|
||||
has no delete route, and the uploaded attachment, which the app's own
|
||||
upload cleanup owns. Both live in `.odysseus-dev/data/`, never in
|
||||
`data/`.
|
||||
@@ -0,0 +1,195 @@
|
||||
"""The area registry and the result table for the release smoke suite.
|
||||
|
||||
Pure stdlib on purpose: this module is the one part of the suite that has
|
||||
to be readable and testable without a running instance, because it is
|
||||
what decides whether the suite's output is honest.
|
||||
|
||||
Two lists matter here and they are both deliberate:
|
||||
|
||||
``COVERED`` names every feature area the suite drives, and the test
|
||||
module that drives it. A row appears in the table whether or not its
|
||||
module ran, so an area cannot quietly vanish from the report by having
|
||||
its file deleted or renamed - it shows up as ``NOT RUN`` instead.
|
||||
|
||||
``DECLARED_GAPS`` names the areas the suite does *not* cover, with the
|
||||
reason. They are printed alongside the results rather than left out,
|
||||
because a smoke report that lists only what it checked reads as
|
||||
coverage of everything it does not mention.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Result labels. ASCII only - no Unicode status glyphs anywhere in the
|
||||
# table (repo convention: no emoji in UI or code).
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
SKIP = "SKIP"
|
||||
NOT_RUN = "NOT RUN"
|
||||
NOT_COVERED = "NOT COVERED"
|
||||
|
||||
# Precedence when one area's module produces several outcomes: a single
|
||||
# failure decides the row, then a skip, then pass.
|
||||
_PRECEDENCE = (FAIL, SKIP, PASS)
|
||||
|
||||
# Table geometry. Wide enough for the longest gap reason to read as a
|
||||
# sentence, narrow enough to survive a normal terminal.
|
||||
TABLE_WIDTH = 100
|
||||
MIN_DETAIL_WIDTH = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Area:
|
||||
"""One advertised feature area and the module that exercises it."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
module: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gap:
|
||||
"""An area this suite does not cover, and why it does not."""
|
||||
|
||||
label: str
|
||||
reason: str
|
||||
|
||||
|
||||
# Order is the order the table prints in: the chat surface first, then
|
||||
# the feature areas README.md advertises, then the setup surface.
|
||||
COVERED = (
|
||||
Area("chat", "Chat", "test_chat_smoke.py"),
|
||||
Area("compare", "Compare", "test_compare_smoke.py"),
|
||||
Area("notes", "Notes", "test_notes_smoke.py"),
|
||||
Area("calendar", "Calendar", "test_calendar_smoke.py"),
|
||||
Area("tasks", "Tasks (scheduled)", "test_tasks_smoke.py"),
|
||||
Area("documents", "Documents (editor)", "test_documents_smoke.py"),
|
||||
Area("documents_rag", "Documents (RAG)", "test_documents_rag_smoke.py"),
|
||||
Area("email", "Email", "test_email_smoke.py"),
|
||||
Area("memory", "Memory", "test_memory_smoke.py"),
|
||||
Area("uploads", "Uploads", "test_uploads_smoke.py"),
|
||||
Area("cookbook", "Cookbook", "test_cookbook_smoke.py"),
|
||||
Area("settings", "Settings", "test_settings_smoke.py"),
|
||||
)
|
||||
|
||||
DECLARED_GAPS = (
|
||||
Gap(
|
||||
"Deep Research",
|
||||
"needs live web egress; the crawler has no deterministic stub and adding "
|
||||
"one would be an application change",
|
||||
),
|
||||
Gap(
|
||||
"Web Search",
|
||||
"needs a reachable SearXNG or an external provider, so the result is not "
|
||||
"reproducible from a clean checkout",
|
||||
),
|
||||
Gap(
|
||||
"Email over IMAP/SMTP",
|
||||
"covered through the existing ODYSSEUS_EMAIL_FIXTURE path only; no local "
|
||||
"mail server, so real account sync and send are untested",
|
||||
),
|
||||
Gap(
|
||||
"Cookbook download and serve",
|
||||
"needs tmux, a GPU runtime and a multi-GB model download; only hardware "
|
||||
"fit and state sync are checked",
|
||||
),
|
||||
Gap(
|
||||
"Gallery and photo editor",
|
||||
"already the one area with Playwright specs under tests/e2e/photo-editor/",
|
||||
),
|
||||
Gap(
|
||||
"Agent tool loop",
|
||||
"measured by the checkpoint benchmark, which is the safety net that does "
|
||||
"cover the agent runtime",
|
||||
),
|
||||
Gap(
|
||||
"MCP servers",
|
||||
"the built-in servers are stdio subprocesses whose readiness is not part "
|
||||
"of the app's own readiness contract",
|
||||
),
|
||||
Gap(
|
||||
"Rendering and layout",
|
||||
"pinned by the computed-style snapshot in "
|
||||
"tests/test_css_computed_style_snapshot.py",
|
||||
),
|
||||
)
|
||||
|
||||
_MODULE_TO_KEY = {area.module: area.key for area in COVERED}
|
||||
|
||||
|
||||
def area_for_module(module_name: str) -> str | None:
|
||||
"""Map a test module filename to its area key, or None."""
|
||||
return _MODULE_TO_KEY.get(module_name)
|
||||
|
||||
|
||||
def resolve(outcomes: list[str]) -> str:
|
||||
"""Collapse one module's outcomes into the row's single result."""
|
||||
if not outcomes:
|
||||
return NOT_RUN
|
||||
for label in _PRECEDENCE:
|
||||
if label in outcomes:
|
||||
return label
|
||||
return NOT_RUN
|
||||
|
||||
|
||||
def render_table(results, *, header="", areas=COVERED, gaps=DECLARED_GAPS,
|
||||
width=TABLE_WIDTH) -> str:
|
||||
"""Render the per-area table.
|
||||
|
||||
``results`` maps an area key to a mapping with ``result`` and,
|
||||
optionally, ``checks`` and ``detail``. Unknown keys are ignored and
|
||||
missing keys render as ``NOT RUN`` - the registry, not the run,
|
||||
decides which rows exist.
|
||||
"""
|
||||
rows = []
|
||||
for area in areas:
|
||||
entry = results.get(area.key) or {}
|
||||
result = entry.get("result") or NOT_RUN
|
||||
checks = entry.get("checks")
|
||||
detail = entry.get("detail") or ""
|
||||
if result == NOT_RUN and not detail:
|
||||
detail = "no test ran for this area"
|
||||
rows.append((area.label, result,
|
||||
"" if checks is None else str(checks), detail))
|
||||
|
||||
labels = [row[0] for row in rows] + [gap.label for gap in gaps] + ["AREA"]
|
||||
label_width = max(len(label) for label in labels)
|
||||
result_width = max([len(row[1]) for row in rows] + [len(NOT_COVERED), len("RESULT")])
|
||||
checks_width = max([len(row[2]) for row in rows] + [len("CHECKS")])
|
||||
# Indent + label + gap + result + gap + checks + gap, then the detail.
|
||||
detail_indent = 2 + label_width + 2 + result_width + 2 + checks_width + 2
|
||||
detail_width = max(width - detail_indent, MIN_DETAIL_WIDTH)
|
||||
|
||||
def row_lines(label, result, checks, detail):
|
||||
first = (f" {label.ljust(label_width)} {result.ljust(result_width)} "
|
||||
f"{checks.rjust(checks_width)} ")
|
||||
wrapped = textwrap.wrap(detail, detail_width) or [""]
|
||||
out = [(first + wrapped[0]).rstrip()]
|
||||
out += [(" " * detail_indent + line).rstrip() for line in wrapped[1:]]
|
||||
return out
|
||||
|
||||
lines = []
|
||||
if header:
|
||||
lines.extend([header, ""])
|
||||
lines.append(
|
||||
f" {'AREA'.ljust(label_width)} {'RESULT'.ljust(result_width)} "
|
||||
f"{'CHECKS'.rjust(checks_width)} DETAIL"
|
||||
)
|
||||
for row in rows:
|
||||
lines.extend(row_lines(*row))
|
||||
|
||||
if gaps:
|
||||
lines.extend(["", " Not covered, deliberately:"])
|
||||
for gap in gaps:
|
||||
lines.extend(row_lines(gap.label, NOT_COVERED, "", gap.reason))
|
||||
|
||||
failed = [row for row in rows if row[1] == FAIL]
|
||||
skipped = [row for row in rows if row[1] == SKIP]
|
||||
not_run = [row for row in rows if row[1] == NOT_RUN]
|
||||
passed = len(rows) - len(failed) - len(skipped) - len(not_run)
|
||||
lines.extend(["", (
|
||||
f" {passed} pass, {len(failed)} fail, {len(skipped)} skip, "
|
||||
f"{len(not_run)} not run, {len(gaps)} declared gaps"
|
||||
)])
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Session wiring for the release smoke suite.
|
||||
|
||||
The suite drives a real instance over HTTP. It never starts one: that is
|
||||
`scripts/odysseus-smoke`'s job, which boots the worktree through
|
||||
`scripts/odysseus-dev` and hands the details over in the environment.
|
||||
Run under a plain `pytest` with no instance up, every scenario skips
|
||||
with the reason rather than failing, so the full suite stays green.
|
||||
|
||||
Three environment values form the contract, and they are exactly what
|
||||
`odysseus dev env` prints plus the dev admin account:
|
||||
|
||||
APP_PORT - which instance, read through
|
||||
`internal_api_base()`
|
||||
ODYSSEUS_ADMIN_USER - the account to authenticate as
|
||||
ODYSSEUS_ADMIN_PASSWORD
|
||||
ODYSSEUS_DATA_DIR - where the email fixture file goes, read
|
||||
through `src.constants.DATA_DIR`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.constants import internal_api_base
|
||||
from tests.helpers.cli_loader import load_script
|
||||
from tests.smoke import areas
|
||||
from tests.smoke.stub_provider import MODEL_PRIMARY, StubProvider
|
||||
|
||||
# How long a smoke request may take. Generous: the first turn through a
|
||||
# cold agent path does real work, and a timeout here reads as a product
|
||||
# failure, which is the one thing this suite must not get wrong.
|
||||
REQUEST_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
# Auth and endpoint routes the suite drives directly. Kept here so a
|
||||
# route rename shows up in one place rather than twelve.
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
HEALTH_PATH = "/api/health"
|
||||
ENDPOINTS_PATH = "/api/model-endpoints"
|
||||
SESSION_PATH = "/api/session"
|
||||
|
||||
_NO_PORT = (
|
||||
"APP_PORT is not set, so there is no instance to drive. Run the suite "
|
||||
"with `scripts/odysseus-smoke`, which boots this worktree and exports it."
|
||||
)
|
||||
|
||||
|
||||
def _reserved_ports() -> dict:
|
||||
"""`odysseus dev`'s own refuse-list, read from the launcher.
|
||||
|
||||
The smoke suite writes and deletes real records, so pointing it at a
|
||||
port that means something - a normal launch of this checkout, the
|
||||
machine's production instance - has to be impossible rather than
|
||||
merely discouraged. Reusing the launcher's table keeps one source of
|
||||
truth instead of a second copy that can drift.
|
||||
"""
|
||||
try:
|
||||
return dict(load_script("odysseus-dev").RESERVED_PORTS)
|
||||
except Exception: # pragma: no cover - launcher absent or unloadable
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url() -> str:
|
||||
"""The instance this run drives, or a skip explaining why there is none."""
|
||||
port = (os.environ.get("APP_PORT") or "").strip()
|
||||
if not port:
|
||||
pytest.skip(_NO_PORT)
|
||||
reason = _reserved_ports().get(int(port)) if port.isdigit() else None
|
||||
if reason:
|
||||
pytest.skip(
|
||||
f"APP_PORT={port} is {reason}. The smoke suite creates and deletes "
|
||||
f"real records, so it refuses to run against that instance."
|
||||
)
|
||||
return internal_api_base()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def account() -> dict:
|
||||
user = (os.environ.get("ODYSSEUS_ADMIN_USER") or "").strip()
|
||||
password = os.environ.get("ODYSSEUS_ADMIN_PASSWORD") or ""
|
||||
if not user or not password:
|
||||
pytest.skip(
|
||||
"ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD are not set, so the "
|
||||
"suite cannot authenticate. Run it with `scripts/odysseus-smoke`."
|
||||
)
|
||||
return {"username": user, "password": password}
|
||||
|
||||
|
||||
def _new_client(base_url: str, account: dict) -> httpx.Client:
|
||||
"""An authenticated client, or a skip naming what the instance said."""
|
||||
client = httpx.Client(base_url=base_url, timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
follow_redirects=True)
|
||||
try:
|
||||
client.get(HEALTH_PATH)
|
||||
except httpx.HTTPError as exc:
|
||||
client.close()
|
||||
pytest.skip(f"no instance answering at {base_url} ({exc}). Boot one with "
|
||||
f"`odysseus dev up`, or run `scripts/odysseus-smoke`.")
|
||||
response = client.post(LOGIN_PATH, json=account)
|
||||
if response.status_code != 200:
|
||||
client.close()
|
||||
pytest.skip(
|
||||
f"could not log in as {account['username']} at {base_url}: "
|
||||
f"HTTP {response.status_code}. The recorded credentials may not "
|
||||
f"match this instance's data dir."
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client(base_url, account):
|
||||
"""One authenticated session shared by every scenario."""
|
||||
handle = _new_client(base_url, account)
|
||||
yield handle
|
||||
handle.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_client(base_url, account):
|
||||
"""A second authenticated session, for asserting something persisted.
|
||||
|
||||
Reading a value back on the same cookie proves the request handler
|
||||
returned it. Reading it back on a new login is the closest a test can
|
||||
get to the user reloading the page.
|
||||
"""
|
||||
handle = _new_client(base_url, account)
|
||||
yield handle
|
||||
handle.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def stub_provider():
|
||||
"""The deterministic provider every model-backed scenario talks to."""
|
||||
with StubProvider() as provider:
|
||||
yield provider
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def stub_endpoint(client, stub_provider) -> str:
|
||||
"""Register the stub as a model endpoint and return its id.
|
||||
|
||||
Registered as `endpoint_kind=local` so the app treats it the way it
|
||||
treats a Cookbook-served model rather than probing it as a hosted
|
||||
API, and removed afterwards so a `--keep-up` instance is not left
|
||||
pointing at a port that has gone away.
|
||||
"""
|
||||
response = client.post(ENDPOINTS_PATH, data={
|
||||
"name": "odysseus-smoke-stub",
|
||||
"base_url": stub_provider.base_url,
|
||||
"endpoint_kind": "local",
|
||||
})
|
||||
if response.status_code != 200:
|
||||
pytest.skip(
|
||||
f"the instance would not register the stub provider at "
|
||||
f"{stub_provider.base_url}: HTTP {response.status_code} "
|
||||
f"{response.text[:200]}"
|
||||
)
|
||||
body = response.json()
|
||||
endpoint_id = str(body.get("id") or "")
|
||||
if not endpoint_id:
|
||||
pytest.skip(f"the endpoint the instance registered has no id: {body}")
|
||||
if MODEL_PRIMARY not in (body.get("models") or []):
|
||||
pytest.skip(
|
||||
f"the instance did not discover {MODEL_PRIMARY} on the stub "
|
||||
f"provider; it saw {body.get('models')}"
|
||||
)
|
||||
yield endpoint_id
|
||||
client.delete(f"{ENDPOINTS_PATH}/{endpoint_id}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_session(client, stub_endpoint):
|
||||
"""A chat session bound to the stub provider, deleted afterwards."""
|
||||
response = client.post(SESSION_PATH, data={
|
||||
"name": "odysseus-smoke",
|
||||
"endpoint_id": stub_endpoint,
|
||||
"model": MODEL_PRIMARY,
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
session_id = response.json()["id"]
|
||||
yield session_id
|
||||
client.delete(f"{SESSION_PATH}/{session_id}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The per-area table
|
||||
# --------------------------------------------------------------------------
|
||||
# One row per area in `areas.COVERED`, built from the outcomes pytest
|
||||
# reports rather than from anything a test asserts about itself, so a
|
||||
# module that never ran cannot report a pass.
|
||||
|
||||
_outcomes: dict[str, list[str]] = {}
|
||||
_details: dict[str, str] = {}
|
||||
_checks: dict[str, int] = {}
|
||||
|
||||
|
||||
def _skip_reason(report) -> str:
|
||||
"""The reason text out of a skip report, best effort."""
|
||||
longrepr = getattr(report, "longrepr", None)
|
||||
if isinstance(longrepr, tuple) and len(longrepr) == 3:
|
||||
reason = str(longrepr[2] or "")
|
||||
return reason.removeprefix("Skipped: ").strip()
|
||||
return str(longrepr or "").strip()
|
||||
|
||||
|
||||
def pytest_runtest_logreport(report):
|
||||
key = areas.area_for_module(os.path.basename(str(report.fspath)))
|
||||
if key is None:
|
||||
return
|
||||
if report.skipped:
|
||||
_outcomes.setdefault(key, []).append(areas.SKIP)
|
||||
_details.setdefault(key, _skip_reason(report))
|
||||
return
|
||||
if report.failed:
|
||||
_outcomes.setdefault(key, []).append(areas.FAIL)
|
||||
_details[key] = f"{report.when} failed: {report.nodeid.split('::')[-1]}"
|
||||
return
|
||||
if report.when == "call" and report.passed:
|
||||
_outcomes.setdefault(key, []).append(areas.PASS)
|
||||
_checks[key] = _checks.get(key, 0) + 1
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
||||
if not _outcomes:
|
||||
return
|
||||
results = {}
|
||||
for key, outcomes in _outcomes.items():
|
||||
results[key] = {
|
||||
"result": areas.resolve(outcomes),
|
||||
"checks": _checks.get(key, 0),
|
||||
"detail": _details.get(key, ""),
|
||||
}
|
||||
|
||||
if all(entry["result"] == areas.SKIP for entry in results.values()):
|
||||
reasons = {entry["detail"] for entry in results.values() if entry["detail"]}
|
||||
terminalreporter.write_line("")
|
||||
terminalreporter.write_line(
|
||||
"release smoke suite skipped: " + (
|
||||
reasons.pop() if len(reasons) == 1 else "; ".join(sorted(reasons))
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
header = f"Odysseus release smoke - {internal_api_base()}"
|
||||
terminalreporter.write_line("")
|
||||
terminalreporter.write_line(areas.render_table(results, header=header))
|
||||
@@ -0,0 +1,172 @@
|
||||
"""A deterministic OpenAI-compatible provider for the smoke suite.
|
||||
|
||||
Every scenario that needs a model talks to this instead of a real
|
||||
endpoint. It binds an ephemeral port on loopback, so no scenario depends
|
||||
on network egress, on a model being downloaded, or on two runs on the
|
||||
same machine picking the same port.
|
||||
|
||||
It answers the two routes the app needs to treat it as a local
|
||||
OpenAI-compatible server: ``GET /v1/models`` for discovery and probing,
|
||||
and ``POST /v1/chat/completions`` for both the buffered and the streamed
|
||||
turn. Each reply is a fixed marker plus the model id, so a test can tell
|
||||
the two models apart in a blind comparison; every request is recorded so
|
||||
a test can assert the user's message actually reached the provider
|
||||
rather than only that some text came back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# Two model ids so the Compare area has something to reveal.
|
||||
MODEL_PRIMARY = "odysseus-smoke-primary"
|
||||
MODEL_SECONDARY = "odysseus-smoke-secondary"
|
||||
MODELS = (MODEL_PRIMARY, MODEL_SECONDARY)
|
||||
|
||||
# The marker each reply starts with. Distinctive enough that finding it
|
||||
# in a response body cannot be a coincidence, and short enough to read
|
||||
# in a failure message.
|
||||
REPLY_MARKER = "ODYSSEUS-SMOKE-REPLY"
|
||||
|
||||
# Bind on loopback, kernel-assigned port. No literal port anywhere.
|
||||
BIND_HOST = "127.0.0.1"
|
||||
BIND_PORT = 0
|
||||
|
||||
|
||||
def reply_for(model: str) -> str:
|
||||
"""The exact assistant text this provider returns for ``model``."""
|
||||
return f"{REPLY_MARKER} {model}"
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Requests the provider has served, for assertions after the fact."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._calls = []
|
||||
|
||||
def record(self, payload: dict) -> None:
|
||||
with self._lock:
|
||||
self._calls.append(payload)
|
||||
|
||||
@property
|
||||
def calls(self) -> list[dict]:
|
||||
with self._lock:
|
||||
return list(self._calls)
|
||||
|
||||
def prompts(self) -> list[str]:
|
||||
"""Every user message this provider has been sent."""
|
||||
out = []
|
||||
for call in self.calls:
|
||||
for message in call.get("messages") or []:
|
||||
if message.get("role") == "user":
|
||||
out.append(str(message.get("content") or ""))
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._calls.clear()
|
||||
|
||||
|
||||
def _handler_for(recorder: _Recorder):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args): # noqa: D102 - silence stderr access log
|
||||
pass
|
||||
|
||||
def _send_json(self, status: int, body: dict) -> None:
|
||||
raw = json.dumps(body).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's contract
|
||||
if self.path.rstrip("/").endswith("/models"):
|
||||
self._send_json(200, {
|
||||
"object": "list",
|
||||
"data": [{"id": name, "object": "model", "owned_by": "smoke"}
|
||||
for name in MODELS],
|
||||
})
|
||||
return
|
||||
self._send_json(404, {"error": {"message": f"no route {self.path}"}})
|
||||
|
||||
def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's contract
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
except ValueError:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
recorder.record(payload)
|
||||
|
||||
model = str(payload.get("model") or MODEL_PRIMARY)
|
||||
text = reply_for(model)
|
||||
if payload.get("stream"):
|
||||
self._send_stream(model, text)
|
||||
return
|
||||
self._send_json(200, {
|
||||
"id": "smoke-completion",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
|
||||
def _send_stream(self, model: str, text: str) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
for chunk in (
|
||||
{"choices": [{"index": 0, "delta": {"content": text}}], "model": model},
|
||||
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "model": model},
|
||||
):
|
||||
self.wfile.write(b"data: " + json.dumps(chunk).encode("utf-8") + b"\n\n")
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
class StubProvider:
|
||||
"""A running stub provider. Use as a context manager."""
|
||||
|
||||
def __init__(self):
|
||||
self.recorder = _Recorder()
|
||||
self._server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), _handler_for(self.recorder))
|
||||
self._server.daemon_threads = True
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._server.server_address[1]
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The OpenAI-compatible base the app should be pointed at."""
|
||||
return f"http://{BIND_HOST}:{self.port}/v1"
|
||||
|
||||
def start(self) -> "StubProvider":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def __enter__(self) -> "StubProvider":
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.stop()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Calendar: an event created through the API shows up in the range the UI asks for."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
CALENDARS_PATH = "/api/calendar/calendars"
|
||||
EVENTS_PATH = "/api/calendar/events"
|
||||
|
||||
SUMMARY = "Odysseus smoke event"
|
||||
# Far enough out that a real local calendar's own entries cannot collide
|
||||
# with the assertion, and fixed relative to now so the window is never
|
||||
# empty for date reasons.
|
||||
DAYS_AHEAD = 30
|
||||
|
||||
|
||||
def test_an_event_round_trips(client):
|
||||
listed_calendars = client.get(CALENDARS_PATH)
|
||||
assert listed_calendars.status_code == 200, listed_calendars.text
|
||||
assert listed_calendars.json().get("calendars"), "no calendar to write an event into"
|
||||
|
||||
start = (datetime.now() + timedelta(days=DAYS_AHEAD)).replace(
|
||||
hour=10, minute=0, second=0, microsecond=0)
|
||||
created = client.post(EVENTS_PATH, json={
|
||||
"summary": SUMMARY,
|
||||
"dtstart": start.isoformat(),
|
||||
})
|
||||
assert created.status_code == 200, created.text
|
||||
uid = created.json()["uid"]
|
||||
try:
|
||||
window = client.get(EVENTS_PATH, params={
|
||||
"start": (start - timedelta(days=1)).isoformat(),
|
||||
"end": (start + timedelta(days=1)).isoformat(),
|
||||
})
|
||||
assert window.status_code == 200, window.text
|
||||
events = window.json().get("events") or []
|
||||
matching = [e for e in events if e.get("uid") == uid]
|
||||
assert matching, [e.get("summary") for e in events]
|
||||
assert matching[0].get("summary") == SUMMARY, matching[0]
|
||||
|
||||
read = client.get(f"{EVENTS_PATH}/{uid}")
|
||||
assert read.status_code == 200, read.text
|
||||
finally:
|
||||
removed = client.delete(f"{EVENTS_PATH}/{uid}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
|
||||
after = client.get(EVENTS_PATH, params={
|
||||
"start": (start - timedelta(days=1)).isoformat(),
|
||||
"end": (start + timedelta(days=1)).isoformat(),
|
||||
})
|
||||
assert uid not in [e.get("uid") for e in after.json().get("events") or []]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Chat: a turn against the stub provider comes back rendered and saved.
|
||||
|
||||
The buffered and the streamed path are both checked because the UI uses
|
||||
the streamed one and the agent's own loop uses the buffered one, and a
|
||||
decomposition can break either alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.smoke.stub_provider import MODEL_PRIMARY, reply_for
|
||||
|
||||
CHAT_PATH = "/api/chat"
|
||||
CHAT_STREAM_PATH = "/api/chat_stream"
|
||||
HISTORY_PATH = "/api/history"
|
||||
|
||||
PROMPT = "Smoke check: reply with anything."
|
||||
|
||||
|
||||
def test_buffered_turn_returns_the_provider_reply(client, chat_session, stub_provider):
|
||||
response = client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body.get("response") == reply_for(MODEL_PRIMARY), body
|
||||
assert body.get("model") == MODEL_PRIMARY, body
|
||||
# The app prefaces the turn with its own date/time context block, so
|
||||
# the prompt is contained in what the provider saw rather than equal
|
||||
# to it.
|
||||
assert any(PROMPT in seen for seen in stub_provider.recorder.prompts()), (
|
||||
"the prompt never reached the provider, so the reply came from "
|
||||
"somewhere other than the model path"
|
||||
)
|
||||
|
||||
|
||||
def test_streamed_turn_emits_the_reply_and_saves_the_message(client, chat_session):
|
||||
deltas, saved = [], []
|
||||
with client.stream("POST", CHAT_STREAM_PATH,
|
||||
json={"message": PROMPT, "session": chat_session}) as response:
|
||||
assert response.status_code == 200
|
||||
for line in response.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[len("data: "):].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if "delta" in event:
|
||||
deltas.append(str(event["delta"]))
|
||||
if event.get("type") == "message_saved":
|
||||
saved.append(event.get("id"))
|
||||
|
||||
assert "".join(deltas) == reply_for(MODEL_PRIMARY), deltas
|
||||
assert saved and saved[0], "the stream never reported the assistant turn as saved"
|
||||
|
||||
|
||||
def test_the_turn_is_in_the_session_history(client, chat_session):
|
||||
client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
|
||||
response = client.get(f"{HISTORY_PATH}/{chat_session}")
|
||||
assert response.status_code == 200, response.text
|
||||
messages = response.json().get("history") or []
|
||||
rendered = [str(m.get("content") or "") for m in messages]
|
||||
assert any(PROMPT in text for text in rendered), rendered
|
||||
assert any(reply_for(MODEL_PRIMARY) in text for text in rendered), rendered
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Compare: a blind comparison streams both sides and reveals them on the vote.
|
||||
|
||||
Two model ids on the one stub provider is what makes this checkable
|
||||
without a second endpoint: each returns a reply naming itself, so the
|
||||
reveal can be matched against which text arrived on which side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.smoke.stub_provider import MODEL_PRIMARY, MODEL_SECONDARY, reply_for
|
||||
|
||||
COMPARE_PATH = "/api/compare"
|
||||
CHAT_STREAM_PATH = "/api/chat_stream"
|
||||
|
||||
PROMPT = "Smoke check: compare two replies."
|
||||
|
||||
|
||||
def _stream_text(client, session_id: str) -> str:
|
||||
deltas = []
|
||||
with client.stream("POST", CHAT_STREAM_PATH,
|
||||
json={"message": PROMPT, "session": session_id}) as response:
|
||||
assert response.status_code == 200
|
||||
for line in response.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[len("data: "):].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if "delta" in event:
|
||||
deltas.append(str(event["delta"]))
|
||||
return "".join(deltas)
|
||||
|
||||
|
||||
def test_a_blind_comparison_streams_and_reveals(client, stub_endpoint):
|
||||
started = client.post(f"{COMPARE_PATH}/start", data={
|
||||
"prompt": PROMPT,
|
||||
"model_a": MODEL_PRIMARY,
|
||||
"model_b": MODEL_SECONDARY,
|
||||
"endpoint_a_id": stub_endpoint,
|
||||
"endpoint_b_id": stub_endpoint,
|
||||
"is_blind": "true",
|
||||
})
|
||||
assert started.status_code == 200, started.text
|
||||
comparison = started.json()
|
||||
comparison_id = comparison["id"]
|
||||
|
||||
# Blind: the start response must not say which model is on which side.
|
||||
assert not comparison.get("model_left"), comparison
|
||||
assert not comparison.get("model_right"), comparison
|
||||
|
||||
left = _stream_text(client, comparison["session_left"])
|
||||
right = _stream_text(client, comparison["session_right"])
|
||||
assert {left, right} == {reply_for(MODEL_PRIMARY), reply_for(MODEL_SECONDARY)}, (left, right)
|
||||
|
||||
voted = client.post(f"{COMPARE_PATH}/{comparison_id}/vote", data={"winner": "left"})
|
||||
assert voted.status_code == 200, voted.text
|
||||
revealed = voted.json().get("revealed") or {}
|
||||
assert revealed.get("left") in (MODEL_PRIMARY, MODEL_SECONDARY), voted.text
|
||||
assert reply_for(revealed["left"]) == left, (revealed, left)
|
||||
assert reply_for(revealed["right"]) == right, (revealed, right)
|
||||
|
||||
history = client.get(f"{COMPARE_PATH}/history")
|
||||
assert history.status_code == 200, history.text
|
||||
entries = [row for row in history.json() if row.get("id") == comparison_id]
|
||||
assert entries, history.text
|
||||
assert entries[0].get("winner"), entries[0]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Cookbook: hardware is detected and the recommendations are sized against it.
|
||||
|
||||
What the README advertises here is hardware-aware recommendation, and
|
||||
that is exactly the part that runs offline. Downloading and serving a
|
||||
model is left to the gap list: it needs tmux, a GPU runtime and several
|
||||
gigabytes over the network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SYSTEM_PATH = "/api/hwfit/system"
|
||||
MODELS_PATH = "/api/hwfit/models"
|
||||
STATE_PATH = "/api/cookbook/state"
|
||||
GPUS_PATH = "/api/cookbook/gpus"
|
||||
|
||||
STATE_MARKER = "odysseusSmokeMarker"
|
||||
|
||||
|
||||
def test_hardware_is_detected(client):
|
||||
response = client.get(SYSTEM_PATH)
|
||||
assert response.status_code == 200, response.text
|
||||
system = response.json()
|
||||
assert (system.get("total_ram_gb") or 0) > 0, system
|
||||
assert (system.get("cpu_cores") or 0) > 0, system
|
||||
assert system.get("cpu_name"), system
|
||||
|
||||
gpus = client.get(GPUS_PATH)
|
||||
assert gpus.status_code == 200, gpus.text
|
||||
assert gpus.json().get("ok") is True, gpus.text
|
||||
|
||||
|
||||
def test_recommendations_fit_the_detected_hardware(client):
|
||||
response = client.get(MODELS_PATH)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
system = body.get("system") or {}
|
||||
assert system.get("cpu_name"), body
|
||||
recommended = body.get("models") or body.get("recommendations") or []
|
||||
assert recommended, f"no model recommendation for this hardware: {list(body)}"
|
||||
|
||||
|
||||
def test_cookbook_state_persists(client):
|
||||
written = client.post(STATE_PATH, json={STATE_MARKER: "ody-95"})
|
||||
assert written.status_code == 200, written.text
|
||||
assert written.json().get("ok") is True, written.text
|
||||
|
||||
read = client.get(STATE_PATH)
|
||||
assert read.status_code == 200, read.text
|
||||
assert read.json().get(STATE_MARKER) == "ody-95", read.text
|
||||
|
||||
client.post(STATE_PATH, json={})
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Documents (RAG): an uploaded file is chunked, indexed and then listed.
|
||||
|
||||
This is the one area whose dependency is not satisfiable from a clean
|
||||
checkout. `requirements.txt` pins `chromadb-client`, the HTTP client;
|
||||
the ChromaDB *server* is a separate install, and without one reachable
|
||||
the app returns a deliberate 503 from the upload route rather than
|
||||
indexing into nothing. So the scenario skips with that reason printed in
|
||||
the table instead of being quietly dropped - a row saying SKIP and why
|
||||
is the honest report, and it goes green as soon as a vector service is
|
||||
there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
PERSONAL_PATH = "/api/personal"
|
||||
UPLOAD_PATH = "/api/personal/upload"
|
||||
|
||||
# The route uniquifies the stored name and lists it under the owner's
|
||||
# upload dir, so assertions match on the stem rather than the filename.
|
||||
STEM = "odysseus-smoke-corpus"
|
||||
FILENAME = f"{STEM}.txt"
|
||||
CONTENT = (
|
||||
"The release smoke suite indexed this file. "
|
||||
"It exists so the retrieval path has something deterministic to chunk."
|
||||
)
|
||||
# The route's own 503 text when no vector store answers.
|
||||
UNAVAILABLE_MARKER = "RAG system is not available"
|
||||
|
||||
|
||||
def test_an_uploaded_file_is_indexed_and_listed(client):
|
||||
response = client.post(UPLOAD_PATH,
|
||||
files={"files": (FILENAME, CONTENT.encode("utf-8"), "text/plain")})
|
||||
if response.status_code == 503 and UNAVAILABLE_MARKER in response.text:
|
||||
pytest.skip(
|
||||
"no vector service reachable, so indexing is unavailable. "
|
||||
"requirements.txt pins chromadb-client, not the server; install "
|
||||
"chromadb in the venv and re-run to cover this area."
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
try:
|
||||
assert body.get("indexed_count", 0) > 0, f"nothing was indexed: {body}"
|
||||
assert body.get("failed_count", 1) == 0, f"a chunk failed to index: {body}"
|
||||
assert FILENAME in (body.get("uploaded") or []), body
|
||||
|
||||
listed = client.get(PERSONAL_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
names = [str(f.get("name")) for f in listed.json().get("files") or []]
|
||||
assert any(STEM in name for name in names), names
|
||||
finally:
|
||||
listed = client.get(PERSONAL_PATH).json().get("files") or []
|
||||
for entry in listed:
|
||||
if STEM in str(entry.get("name")):
|
||||
client.request("DELETE", "/api/personal/file",
|
||||
params={"filepath": entry.get("path")})
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Documents: the editor's create, edit and version history survive a round trip."""
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENT_PATH = "/api/document"
|
||||
LIBRARY_PATH = "/api/documents/library"
|
||||
|
||||
TITLE = "Odysseus smoke document"
|
||||
FIRST = "First revision, written by the release smoke suite."
|
||||
SECOND = "Second revision, written by the release smoke suite."
|
||||
|
||||
|
||||
def test_a_document_round_trips_with_its_versions(client):
|
||||
created = client.post(DOCUMENT_PATH, json={"title": TITLE, "content": FIRST})
|
||||
assert created.status_code == 200, created.text
|
||||
body = created.json()
|
||||
doc_id = body["id"]
|
||||
try:
|
||||
assert body.get("current_content") == FIRST, body
|
||||
assert body.get("version_count") == 1, body
|
||||
|
||||
library = client.get(LIBRARY_PATH)
|
||||
assert library.status_code == 200, library.text
|
||||
assert doc_id in [d.get("id") for d in library.json().get("documents") or []]
|
||||
|
||||
# `force_version` because a save inside the route's coalesce
|
||||
# window updates the current version in place instead of adding
|
||||
# one - which is right for autosave and would make a smoke check
|
||||
# that edits immediately depend on the clock.
|
||||
edited = client.put(f"{DOCUMENT_PATH}/{doc_id}",
|
||||
json={"content": SECOND, "force_version": True})
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert edited.json().get("current_content") == SECOND, edited.text
|
||||
assert edited.json().get("version_count") == 2, edited.text
|
||||
|
||||
versions = client.get(f"{DOCUMENT_PATH}/{doc_id}/versions")
|
||||
assert versions.status_code == 200, versions.text
|
||||
contents = {v.get("version_number"): v.get("content") for v in versions.json()}
|
||||
assert contents.get(1) == FIRST, contents
|
||||
assert contents.get(2) == SECOND, contents
|
||||
|
||||
restored = client.post(f"{DOCUMENT_PATH}/{doc_id}/restore/1")
|
||||
assert restored.status_code == 200, restored.text
|
||||
assert client.get(f"{DOCUMENT_PATH}/{doc_id}").json()["current_content"] == FIRST
|
||||
finally:
|
||||
removed = client.delete(f"{DOCUMENT_PATH}/{doc_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Email: the inbox lists a seeded message, opens it, and marks it read.
|
||||
|
||||
Email is the one area with no way to reach a real account deterministically,
|
||||
and the repo already solved that: `routes/email_routes.py` carries a
|
||||
fixture path gated on `ODYSSEUS_EMAIL_FIXTURE=1` plus a fixture file in
|
||||
the data dir. This uses that mechanism rather than inventing a second
|
||||
one - which means it also only covers what the fixture covers. Real IMAP
|
||||
sync and SMTP send stay out, and say so in the table's gap list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
LIST_PATH = "/api/email/list"
|
||||
READ_PATH = "/api/email/read"
|
||||
MARK_READ_PATH = "/api/email/mark-read"
|
||||
UNREAD_STATE_PATH = "/api/email/unread-state"
|
||||
|
||||
# The filename the fixture path reads. Same value as
|
||||
# routes/email_routes.py's `_fixture_email_file`.
|
||||
FIXTURE_FILENAME = "fixture_email_messages.json"
|
||||
|
||||
SUBJECT = "Odysseus smoke inbox message"
|
||||
BODY = "Body of the smoke fixture message."
|
||||
SENDER = "Smoke Sender <smoke@example.invalid>"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_inbox(client, account):
|
||||
"""Write the fixture inbox, and put back whatever was there before.
|
||||
|
||||
The flag itself has to be in the app's environment, which is the
|
||||
launcher's job; if it is missing the fixture path stays off and the
|
||||
list route falls through to a real account that does not exist. That
|
||||
reads as a skip, not a failure.
|
||||
"""
|
||||
path = Path(DATA_DIR) / FIXTURE_FILENAME
|
||||
previous = path.read_bytes() if path.exists() else None
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"messages": [{
|
||||
"owner": account["username"],
|
||||
"from": SENDER,
|
||||
"subject": SUBJECT,
|
||||
"date": "2026-09-29T12:00:00+00:00",
|
||||
"body": BODY,
|
||||
}]}, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
yield path
|
||||
finally:
|
||||
if previous is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
path.write_bytes(previous)
|
||||
|
||||
|
||||
def _fixture_rows(client):
|
||||
response = client.get(LIST_PATH, params={"folder": "INBOX", "limit": 10})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
rows = [e for e in body.get("emails") or [] if e.get("subject") == SUBJECT]
|
||||
if not rows:
|
||||
pytest.skip(
|
||||
"the instance is not serving the email fixture, so there is no "
|
||||
"deterministic inbox to read. Boot it with ODYSSEUS_EMAIL_FIXTURE=1 "
|
||||
"(scripts/odysseus-smoke does)."
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_the_inbox_lists_opens_and_marks_a_message(client, seeded_inbox):
|
||||
row = _fixture_rows(client)[0]
|
||||
uid = row["uid"]
|
||||
assert row.get("from_address") == "smoke@example.invalid", row
|
||||
assert row.get("is_read") is False, row
|
||||
|
||||
read = client.get(f"{READ_PATH}/{uid}", params={"folder": "INBOX"})
|
||||
assert read.status_code == 200, read.text
|
||||
opened = read.json()
|
||||
assert opened.get("subject") == SUBJECT, opened
|
||||
assert BODY in str(opened.get("body") or ""), opened
|
||||
assert BODY in str(opened.get("body_html") or ""), opened
|
||||
|
||||
before = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
|
||||
assert before.status_code == 200, before.text
|
||||
assert before.json().get("unread_count") == 1, before.text
|
||||
|
||||
marked = client.post(f"{MARK_READ_PATH}/{uid}", params={"folder": "INBOX"})
|
||||
assert marked.status_code == 200, marked.text
|
||||
|
||||
after = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
|
||||
assert after.json().get("unread_count") == 0, after.text
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Memory: a stored fact is listed, found by search, and gone after delete.
|
||||
|
||||
Keyword mode is enough here on purpose. The memory store degrades to
|
||||
keyword matching when no vector service answers, and that degraded path
|
||||
is the one a clean checkout actually runs, so it is the one worth
|
||||
smoking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
MEMORY_PATH = "/api/memory"
|
||||
ADD_PATH = "/api/memory/add"
|
||||
SEARCH_PATH = "/api/memory/search"
|
||||
|
||||
# A token that cannot collide with a real memory on a scratch instance.
|
||||
TOKEN = "odysseus-smoke-marker-quintile"
|
||||
TEXT = f"The release smoke suite stored the token {TOKEN} as a fact."
|
||||
|
||||
|
||||
def test_a_memory_round_trips(client):
|
||||
created = client.post(ADD_PATH, json={"text": TEXT, "category": "fact"})
|
||||
assert created.status_code == 200, created.text
|
||||
assert created.json().get("ok") is True, created.text
|
||||
|
||||
listed = client.get(MEMORY_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
matching = [m for m in listed.json().get("memory") or [] if TOKEN in str(m.get("text"))]
|
||||
assert matching, [m.get("text") for m in listed.json().get("memory") or []]
|
||||
memory_id = matching[0]["id"]
|
||||
|
||||
try:
|
||||
found = client.post(SEARCH_PATH, data={"query": TOKEN})
|
||||
assert found.status_code == 200, found.text
|
||||
hits = [m for m in found.json().get("memories") or [] if TOKEN in str(m.get("text"))]
|
||||
assert hits, found.text
|
||||
finally:
|
||||
removed = client.delete(f"{MEMORY_PATH}/{memory_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
|
||||
remaining = client.get(MEMORY_PATH).json().get("memory") or []
|
||||
assert memory_id not in [m.get("id") for m in remaining]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Notes: a note created through the API is readable, editable and gone after delete."""
|
||||
from __future__ import annotations
|
||||
|
||||
NOTES_PATH = "/api/notes"
|
||||
|
||||
TITLE = "Odysseus smoke note"
|
||||
BODY = "Created by the release smoke suite."
|
||||
EDITED_BODY = "Edited by the release smoke suite."
|
||||
|
||||
|
||||
def test_a_note_round_trips(client):
|
||||
created = client.post(NOTES_PATH, json={"title": TITLE, "content": BODY})
|
||||
assert created.status_code == 200, created.text
|
||||
note_id = created.json()["id"]
|
||||
try:
|
||||
listed = client.get(NOTES_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
titles = [n.get("title") for n in listed.json().get("notes") or []]
|
||||
assert TITLE in titles, titles
|
||||
|
||||
read = client.get(f"{NOTES_PATH}/{note_id}")
|
||||
assert read.status_code == 200, read.text
|
||||
assert read.json().get("content") == BODY, read.text
|
||||
|
||||
edited = client.put(f"{NOTES_PATH}/{note_id}",
|
||||
json={"title": TITLE, "content": EDITED_BODY})
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert client.get(f"{NOTES_PATH}/{note_id}").json()["content"] == EDITED_BODY
|
||||
finally:
|
||||
removed = client.delete(f"{NOTES_PATH}/{note_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert client.get(f"{NOTES_PATH}/{note_id}").status_code == 404
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Settings: a preference written through the API survives a new login.
|
||||
|
||||
Reading the value back on the same cookie only proves the handler
|
||||
answered. Reading it back after authenticating again is what proves it
|
||||
was persisted rather than held in the session, which is the closest an
|
||||
API-level check gets to the user reloading the page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PREFS_PATH = "/api/prefs"
|
||||
|
||||
KEY = "odysseus_smoke_preference"
|
||||
VALUE = "set-by-the-release-smoke-suite"
|
||||
|
||||
|
||||
def test_a_preference_survives_a_new_login(client, fresh_client):
|
||||
written = client.put(f"{PREFS_PATH}/{KEY}", json={"value": VALUE})
|
||||
assert written.status_code == 200, written.text
|
||||
assert written.json().get("value") == VALUE, written.text
|
||||
|
||||
read = client.get(f"{PREFS_PATH}/{KEY}")
|
||||
assert read.status_code == 200, read.text
|
||||
assert read.json().get("value") == VALUE, read.text
|
||||
|
||||
reloaded = fresh_client.get(f"{PREFS_PATH}/{KEY}")
|
||||
assert reloaded.status_code == 200, reloaded.text
|
||||
assert reloaded.json().get("value") == VALUE, reloaded.text
|
||||
|
||||
listed = fresh_client.get(PREFS_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert listed.json().get(KEY) == VALUE, listed.text
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tasks: a scheduled task is created with a computed next run and is listed.
|
||||
|
||||
Deliberately not fired. Running a task is model and tool work the
|
||||
checkpoint benchmark covers; what this asserts is that the scheduler
|
||||
still accepts a task and computes when it should run, which is the part
|
||||
a route move can break silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
TASKS_PATH = "/api/tasks"
|
||||
|
||||
NAME = "Odysseus smoke task"
|
||||
SCHEDULED_TIME = "03:00"
|
||||
|
||||
|
||||
def test_a_scheduled_task_round_trips(client):
|
||||
created = client.post(TASKS_PATH, json={
|
||||
"name": NAME,
|
||||
"task_type": "llm",
|
||||
"prompt": "Smoke task; never run by this suite.",
|
||||
"trigger_type": "schedule",
|
||||
"schedule": "daily",
|
||||
"scheduled_time": SCHEDULED_TIME,
|
||||
})
|
||||
assert created.status_code == 200, created.text
|
||||
body = created.json()
|
||||
task_id = body["id"]
|
||||
try:
|
||||
assert body.get("next_run"), f"no next run computed for a daily task: {body}"
|
||||
assert body.get("status") == "active", body
|
||||
|
||||
listed = client.get(TASKS_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert task_id in [t.get("id") for t in listed.json().get("tasks") or []]
|
||||
|
||||
paused = client.post(f"{TASKS_PATH}/{task_id}/pause")
|
||||
assert paused.status_code == 200, paused.text
|
||||
assert client.get(f"{TASKS_PATH}/{task_id}").json().get("status") == "paused"
|
||||
finally:
|
||||
removed = client.delete(f"{TASKS_PATH}/{task_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Uploads: a file uploaded through the chat attachment route reads back byte for byte."""
|
||||
from __future__ import annotations
|
||||
|
||||
UPLOAD_PATH = "/api/upload"
|
||||
STATS_PATH = "/api/upload/stats"
|
||||
|
||||
FILENAME = "odysseus-smoke-attachment.txt"
|
||||
CONTENT = b"Uploaded by the release smoke suite."
|
||||
|
||||
|
||||
def test_an_upload_reads_back_unchanged(client):
|
||||
response = client.post(UPLOAD_PATH,
|
||||
files={"files": (FILENAME, CONTENT, "text/plain")})
|
||||
assert response.status_code == 200, response.text
|
||||
files = response.json().get("files") or []
|
||||
assert len(files) == 1, response.text
|
||||
entry = files[0]
|
||||
assert entry.get("name") == FILENAME, entry
|
||||
assert entry.get("size") == len(CONTENT), entry
|
||||
|
||||
fetched = client.get(f"{UPLOAD_PATH}/{entry['id']}")
|
||||
assert fetched.status_code == 200, fetched.text
|
||||
assert fetched.content == CONTENT, fetched.content
|
||||
|
||||
stats = client.get(STATS_PATH)
|
||||
assert stats.status_code == 200, stats.text
|
||||
assert stats.json().get("total_files", 0) >= 1, stats.text
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -13,13 +14,17 @@ def test_shared_action_menu_order_is_used_by_item_menus() -> None:
|
||||
"static/js/tasks.js": "orderActionMenuItems",
|
||||
"static/js/sessions.js": "orderActionMenuItems",
|
||||
"static/js/research/panel.js": "orderActionMenuItems",
|
||||
"static/js/emailLibrary.js": "orderActionMenuItems",
|
||||
"static/js/memory.js": "orderActionMenuItems",
|
||||
}
|
||||
for relative_path, helper in expected_imports.items():
|
||||
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
assert "actionMenuOrder.js" in source
|
||||
assert helper in source
|
||||
# The email library is a package, so the import and the call can sit in
|
||||
# different modules of it.
|
||||
email = email_library_source()
|
||||
assert "actionMenuOrder.js" in email
|
||||
assert "orderActionMenuItems" in email
|
||||
|
||||
|
||||
def test_common_action_order_matches_product_convention() -> None:
|
||||
@@ -68,20 +73,20 @@ def test_dropdown_select_actions_use_the_canonical_icon() -> None:
|
||||
"static/js/sessions.js",
|
||||
"static/js/skills.js",
|
||||
"static/js/tasks.js",
|
||||
"static/js/emailLibrary.js",
|
||||
"static/js/research/panel.js",
|
||||
):
|
||||
module = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
assert "SELECT_MENU_ICON" in module
|
||||
assert "SELECT_MENU_ICON" in email_library_source()
|
||||
|
||||
|
||||
def test_email_filter_menu_has_context_title() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
assert 'email-filter-menu-title">Filter by...</div>' in source
|
||||
|
||||
|
||||
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
style = app_css()
|
||||
assert 'email-settings-auto-reply-section' in source
|
||||
assert 'email-settings-display-enabled-state' in source
|
||||
@@ -90,14 +95,14 @@ def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
||||
|
||||
|
||||
def test_email_search_options_menu_has_context_title() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
menu_start = source.index('id="email-search-options-menu"')
|
||||
menu_end = source.index("</div>", menu_start) + len("</div>")
|
||||
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
|
||||
|
||||
|
||||
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
assert "function _emailTimelineGapThreshold(items)" in source
|
||||
assert "email-date-gap-break" in source
|
||||
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
|
||||
@@ -107,7 +112,7 @@ def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
||||
|
||||
|
||||
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
assert '<option value="tag:action-needed">' not in source
|
||||
assert "filter:tag:action-needed" not in source
|
||||
assert "email-card-favorite" in source
|
||||
@@ -132,7 +137,7 @@ def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
||||
|
||||
|
||||
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
assert "function _todayDateInputValue()" in source
|
||||
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
|
||||
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
|
||||
@@ -140,7 +145,7 @@ def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
|
||||
|
||||
|
||||
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
|
||||
assert "summary: 'Email Auto Reply (away)'" in source
|
||||
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
|
||||
@@ -155,7 +160,7 @@ def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
||||
|
||||
|
||||
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
style = app_css()
|
||||
assert 'email-account-away-label">(AWAY)</span>' in source
|
||||
assert 'id="email-lib-auto-reply-badge"' in source
|
||||
@@ -175,14 +180,14 @@ def test_email_settings_show_away_account_and_compact_display_controls() -> None
|
||||
|
||||
|
||||
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
|
||||
assert "email-settings-clean-btn" in cleanup
|
||||
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
|
||||
|
||||
|
||||
def test_email_settings_escape_returns_to_email_list() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
|
||||
assert settings_guard in source
|
||||
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
|
||||
@@ -190,7 +195,7 @@ def test_email_settings_escape_returns_to_email_list() -> None:
|
||||
|
||||
|
||||
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
select_guard = "if (state._selectMode) {"
|
||||
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
|
||||
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
|
||||
@@ -206,7 +211,7 @@ def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
|
||||
|
||||
|
||||
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
start = source.index("function _askAgentToUnsubscribe")
|
||||
end = source.index("function _unsubscribeCandidateUids", start)
|
||||
prompt = source[start:end]
|
||||
@@ -219,7 +224,7 @@ def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None
|
||||
|
||||
|
||||
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
start = source.index("function _bindEmailSettingsPageControls")
|
||||
end = source.index("function _setUnsubButtonBusy", start)
|
||||
controls = source[start:end]
|
||||
@@ -228,7 +233,7 @@ def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
||||
|
||||
|
||||
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
frontend = email_library_source()
|
||||
stylesheet = app_css()
|
||||
assert "email-unsub-duplicate-badge" in frontend
|
||||
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
||||
@@ -236,7 +241,7 @@ def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
|
||||
|
||||
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
frontend = email_library_source()
|
||||
stylesheet = app_css()
|
||||
start = frontend.index("function _emailCleanupSettingsHtml")
|
||||
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
||||
@@ -257,8 +262,8 @@ def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
||||
|
||||
|
||||
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
|
||||
frontend = email_library_source()
|
||||
backend = (ROOT / "routes/email/email_routes.py").read_text(encoding="utf-8")
|
||||
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
||||
assert "async function _deleteAfterUnsubscribe" in frontend
|
||||
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
|
||||
@@ -271,7 +276,7 @@ def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
|
||||
|
||||
|
||||
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
start = source.index("function _agentDeletedEmailUids")
|
||||
end = source.index("function _handleAgentEmailToolOutput", start)
|
||||
resolver = source[start:end]
|
||||
@@ -282,7 +287,7 @@ def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> Non
|
||||
|
||||
|
||||
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
source = email_library_source()
|
||||
start = source.index("function _agentBrowserUnsubscribeSucceeded")
|
||||
end = source.index("function _agentDeletedEmailUids", start)
|
||||
browser_flow = source[start:end]
|
||||
@@ -311,7 +316,7 @@ def test_email_mutation_tool_events_include_exact_arguments() -> None:
|
||||
|
||||
|
||||
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
|
||||
source = (ROOT / "routes" / "email_routes.py").read_text()
|
||||
source = (ROOT / "routes" / "email" / "email_routes.py").read_text()
|
||||
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
|
||||
assert 'scope == "sender_unsubscribe"' in cleanup
|
||||
assert "_unsubscribe_sender_uids_sync" in cleanup
|
||||
@@ -321,7 +326,7 @@ def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> No
|
||||
|
||||
|
||||
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
source = email_library_source()
|
||||
start = source.index("function _markUnsubscribeCardDone")
|
||||
end = source.index("async function _runUnsubscribeCleanup", start)
|
||||
card = source[start:end]
|
||||
@@ -331,7 +336,7 @@ def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> Non
|
||||
|
||||
|
||||
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
source = email_library_source()
|
||||
styles = app_css()
|
||||
assert "email-unsub-ignore-btn" in source
|
||||
assert "_rememberUnsubscribeIgnored(c)" in source
|
||||
@@ -340,7 +345,7 @@ def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None
|
||||
|
||||
|
||||
def test_email_settings_sections_use_static_headers() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
source = email_library_source()
|
||||
styles = app_css()
|
||||
assert 'class="email-unsub-accent-icon"' in source
|
||||
assert 'M12 0L14.59 8.41' in source
|
||||
@@ -372,7 +377,7 @@ def test_email_settings_sections_use_static_headers() -> None:
|
||||
|
||||
|
||||
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
|
||||
backend = (ROOT / "routes" / "email_routes.py").read_text()
|
||||
backend = (ROOT / "routes" / "email" / "email_routes.py").read_text()
|
||||
schema = (ROOT / "src" / "tool_schemas.py").read_text()
|
||||
agent = (ROOT / "src" / "agent_loop.py").read_text()
|
||||
scan_start = backend.index('@router.get("/unsubscribe/scan")')
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
from tests.helpers.js_modules import email_library_paths
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOCUMENT_JS = document_source()
|
||||
CHAT_JS = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
APP_JS = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||
SETTINGS_JS = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
|
||||
# The writing-style panel moved into static/js/settings/writingStyle.js; read
|
||||
# the whole settings surface so this pins behaviour rather than a filename.
|
||||
SETTINGS_JS = "\n".join(
|
||||
p.read_text(encoding="utf-8")
|
||||
for p in [ROOT / "static/js/settings.js", *sorted((ROOT / "static/js/settings").glob("*.js"))]
|
||||
)
|
||||
INDEX_HTML = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
CHAT_ROUTE = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_visible_or_minimized_linked_document_is_sent_as_chat_context():
|
||||
function = DOCUMENT_JS.split("export function getChatDocumentId()", 1)[1].split(
|
||||
"export function getActiveEmailComposerContext()", 1
|
||||
)[0]
|
||||
function = function_body("getChatDocumentId")
|
||||
assert "pane?.isConnected" in function
|
||||
assert "document.body.classList.contains('doc-view')" not in function
|
||||
assert "style?.display !== 'none'" in function
|
||||
@@ -42,8 +47,8 @@ def test_all_runtime_document_imports_share_one_module_url():
|
||||
ROOT / "static/js/chat.js",
|
||||
ROOT / "static/js/chatStream.js",
|
||||
ROOT / "static/js/chatRenderer.js",
|
||||
ROOT / "static/js/emailLibrary.js",
|
||||
ROOT / "static/js/slashCommands.js",
|
||||
*email_library_paths(include_wrapper=True),
|
||||
]
|
||||
versions = {
|
||||
match
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -32,7 +33,7 @@ def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
|
||||
app_src = (ROOT / "static/app.js").read_text()
|
||||
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||
inbox_src = (ROOT / "static/js/emailInbox.js").read_text()
|
||||
library_src = (ROOT / "static/js/emailLibrary.js").read_text()
|
||||
library_src = email_library_source()
|
||||
|
||||
assert '@router.get("/events/{uid}")' in routes_src
|
||||
assert "async function _fetchEventByUid" in calendar_src
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -285,13 +286,10 @@ def test_calendar_tool_guidance_preserves_manual_tags_on_unrelated_updates():
|
||||
|
||||
def test_calendar_visual_asset_versions_are_bumped():
|
||||
versions = []
|
||||
for rel in (
|
||||
"static/app.js",
|
||||
"static/js/chatRenderer.js",
|
||||
"static/js/emailInbox.js",
|
||||
"static/js/emailLibrary.js",
|
||||
):
|
||||
src = (ROOT / rel).read_text()
|
||||
for src in [
|
||||
(ROOT / rel).read_text()
|
||||
for rel in ("static/app.js", "static/js/chatRenderer.js", "static/js/emailInbox.js")
|
||||
] + [email_library_source()]:
|
||||
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
|
||||
assert match
|
||||
versions.append(match.group(1))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.js_modules import email_library_paths
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -32,14 +33,17 @@ def test_library_chat_card_menu_uses_standard_anchor_gap():
|
||||
|
||||
|
||||
def test_card_menus_use_the_same_anchor_gap():
|
||||
for relative_path in (
|
||||
"static/js/sessions.js",
|
||||
"static/js/documentLibrary.js",
|
||||
"static/js/emailLibrary.js",
|
||||
"static/js/memory.js",
|
||||
"static/js/tasks.js",
|
||||
"static/js/skills.js",
|
||||
):
|
||||
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
assert "rect.bottom + 2" not in source, relative_path
|
||||
assert "r.bottom + 2" not in source, relative_path
|
||||
modules = [
|
||||
ROOT / relative_path
|
||||
for relative_path in (
|
||||
"static/js/sessions.js",
|
||||
"static/js/documentLibrary.js",
|
||||
"static/js/memory.js",
|
||||
"static/js/tasks.js",
|
||||
"static/js/skills.js",
|
||||
)
|
||||
] + email_library_paths(include_wrapper=True)
|
||||
for module in modules:
|
||||
source = module.read_text(encoding="utf-8")
|
||||
assert "rect.bottom + 2" not in source, module
|
||||
assert "r.bottom + 2" not in source, module
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user