Add Start/End work transitions, capture field, and PWA support
This commit is contained in:
177
main.py
177
main.py
@ -52,12 +52,24 @@ TODO_KEYWORD_RE = re.compile(r"^(" + "|".join(TODO_KEYWORDS) + r")\b\s*(.*)", re
|
|||||||
DEFAULT_CONFIG_PATH = ROOT_DIR / "config.yaml"
|
DEFAULT_CONFIG_PATH = ROOT_DIR / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def excluded_dir_names() -> set[str]:
|
||||||
|
"""Directories to skip while scanning, from defaults plus DIRS_TO_EXCLUDE env var."""
|
||||||
|
excluded = {".git", ".venv", "venv", "__pycache__"}
|
||||||
|
raw = os.environ.get("DIRS_TO_EXCLUDE", "")
|
||||||
|
for name in raw.split(","):
|
||||||
|
name = name.strip()
|
||||||
|
if name:
|
||||||
|
excluded.add(name)
|
||||||
|
return excluded
|
||||||
|
|
||||||
|
|
||||||
def scan_org_files(base_dir: Path) -> list[OrgFile]:
|
def scan_org_files(base_dir: Path) -> list[OrgFile]:
|
||||||
"""Recursively find .org files under base_dir and return their contents."""
|
"""Recursively find .org files under base_dir and return their contents."""
|
||||||
org_files: list[OrgFile] = []
|
org_files: list[OrgFile] = []
|
||||||
|
excluded = excluded_dir_names()
|
||||||
|
|
||||||
for root, dirs, files in os.walk(base_dir):
|
for root, dirs, files in os.walk(base_dir):
|
||||||
dirs[:] = [d for d in dirs if d not in {".git", ".venv", "venv", "__pycache__"}]
|
dirs[:] = [d for d in dirs if d not in excluded]
|
||||||
root_path = Path(root)
|
root_path = Path(root)
|
||||||
for filename in files:
|
for filename in files:
|
||||||
if filename.lower().endswith(".org"):
|
if filename.lower().endswith(".org"):
|
||||||
@ -445,13 +457,16 @@ def render_org_to_html(
|
|||||||
for t in heading_tags
|
for t in heading_tags
|
||||||
)
|
)
|
||||||
html_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
|
html_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
|
||||||
if keyword == "TODO" and show_webhook:
|
if keyword and show_webhook:
|
||||||
safe_file = html.escape(source_relative_path, quote=True)
|
safe_file = html.escape(source_relative_path, quote=True)
|
||||||
safe_heading = html.escape(bare_text, quote=True)
|
safe_heading = html.escape(bare_text, quote=True)
|
||||||
html_lines.append(
|
html_lines.append(
|
||||||
f"<button class='webhook-send-btn' type='button' "
|
f"<button class='webhook-send-btn' type='button' "
|
||||||
f"data-file='{safe_file}' data-heading='{safe_heading}'>"
|
f"data-file='{safe_file}' data-heading='{safe_heading}' "
|
||||||
f"Start work</button>"
|
f"data-state='STRT'>Start work</button> "
|
||||||
|
f"<button class='webhook-send-btn' type='button' "
|
||||||
|
f"data-file='{safe_file}' data-heading='{safe_heading}' "
|
||||||
|
f"data-state='DONE'>End work</button>"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -484,7 +499,7 @@ _NOTE_TAKEN_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_heading_data(content: str, heading_text: str, file_id: str = "") -> dict | None:
|
def extract_heading_data(content: str, heading_text: str, file_id: str = "", state: str = "STRT") -> dict | None:
|
||||||
"""Find a heading in *content* by its text and extract webhook-ready data."""
|
"""Find a heading in *content* by its text and extract webhook-ready data."""
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
target_idx: int | None = None
|
target_idx: int | None = None
|
||||||
@ -512,7 +527,7 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
|
|||||||
full_rest = hm.group(2) if hm else ""
|
full_rest = hm.group(2) if hm else ""
|
||||||
|
|
||||||
kw_match = TODO_KEYWORD_RE.match(full_rest)
|
kw_match = TODO_KEYWORD_RE.match(full_rest)
|
||||||
state = kw_match.group(1) if kw_match else ""
|
heading_keyword = kw_match.group(1) if kw_match else ""
|
||||||
description = kw_match.group(2) if kw_match else full_rest
|
description = kw_match.group(2) if kw_match else full_rest
|
||||||
|
|
||||||
tag_match = _ORG_TAG_RE.search(description)
|
tag_match = _ORG_TAG_RE.search(description)
|
||||||
@ -536,16 +551,19 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
|
|||||||
timestamps: list[str] = []
|
timestamps: list[str] = []
|
||||||
in_drawer: str | None = None
|
in_drawer: str | None = None
|
||||||
drawer_lines: list[str] = []
|
drawer_lines: list[str] = []
|
||||||
|
properties_seen = False
|
||||||
|
|
||||||
for sl in sub_lines:
|
for sl in sub_lines:
|
||||||
stripped = sl.strip()
|
stripped = sl.strip()
|
||||||
if in_drawer is not None:
|
if in_drawer is not None:
|
||||||
if _DRAWER_END_RE.match(stripped):
|
if _DRAWER_END_RE.match(stripped):
|
||||||
if in_drawer == "PROPERTIES":
|
if in_drawer == "PROPERTIES":
|
||||||
for dl in drawer_lines:
|
if not properties_seen:
|
||||||
pm = _PROP_KV_RE.match(dl)
|
for dl in drawer_lines:
|
||||||
if pm:
|
pm = _PROP_KV_RE.match(dl)
|
||||||
properties[pm.group(1)] = pm.group(2)
|
if pm:
|
||||||
|
properties[pm.group(1)] = pm.group(2)
|
||||||
|
properties_seen = True
|
||||||
else:
|
else:
|
||||||
drawers[in_drawer] = list(drawer_lines)
|
drawers[in_drawer] = list(drawer_lines)
|
||||||
in_drawer = None
|
in_drawer = None
|
||||||
@ -571,7 +589,7 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
|
|||||||
return {
|
return {
|
||||||
"description": description,
|
"description": description,
|
||||||
"labels": tags,
|
"labels": tags,
|
||||||
"state": "STRT",
|
"state": state,
|
||||||
"timestamps": timestamps,
|
"timestamps": timestamps,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"drawers": drawers,
|
"drawers": drawers,
|
||||||
@ -583,6 +601,65 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_to_inbox(content: str, text: str) -> str:
|
||||||
|
"""Append a '** TODO <text>' item under a top-level '* Inbox' section.
|
||||||
|
|
||||||
|
Creates the Inbox section at the bottom of the file if it does not exist.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return content
|
||||||
|
new_task = f"** TODO {text}"
|
||||||
|
inbox_re = re.compile(r"^\* Inbox\s*$")
|
||||||
|
top_level_re = re.compile(r"^\*\s")
|
||||||
|
|
||||||
|
lines = content.rstrip("\n").splitlines()
|
||||||
|
inbox_idx: int | None = None
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
if inbox_re.match(line):
|
||||||
|
inbox_idx = idx
|
||||||
|
|
||||||
|
if inbox_idx is None:
|
||||||
|
lines.append("* Inbox")
|
||||||
|
inbox_idx = len(lines) - 1
|
||||||
|
|
||||||
|
end = len(lines)
|
||||||
|
for idx in range(inbox_idx + 1, len(lines)):
|
||||||
|
if top_level_re.match(lines[idx]):
|
||||||
|
end = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
lines.insert(end, new_task)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def update_heading_keyword(content: str, heading_text: str, new_keyword: str) -> str | None:
|
||||||
|
"""Set the TODO keyword on a heading (identified by its bare text) in *content*.
|
||||||
|
|
||||||
|
Returns the updated content, or None if the heading could not be found.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
for idx, raw_line in enumerate(lines):
|
||||||
|
hm = _HEADING_RE.match(raw_line)
|
||||||
|
if not hm:
|
||||||
|
continue
|
||||||
|
rest = hm.group(2)
|
||||||
|
kw_match = TODO_KEYWORD_RE.match(rest)
|
||||||
|
bare_title = kw_match.group(2) if kw_match else rest
|
||||||
|
tag_match = _ORG_TAG_RE.search(bare_title)
|
||||||
|
bare_title_no_tags = bare_title[: tag_match.start()] if tag_match else bare_title
|
||||||
|
if bare_title_no_tags.strip() != heading_text.strip():
|
||||||
|
continue
|
||||||
|
heading_marker = hm.group(1)
|
||||||
|
if kw_match:
|
||||||
|
new_rest = f"{new_keyword} {kw_match.group(2)}".rstrip()
|
||||||
|
else:
|
||||||
|
new_rest = f"{new_keyword} {rest}"
|
||||||
|
lines[idx] = f"{heading_marker} {new_rest}"
|
||||||
|
return "\n".join(lines)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_org_ts_to_iso(raw: str) -> str:
|
def _parse_org_ts_to_iso(raw: str) -> str:
|
||||||
"""Convert an org timestamp body like '2026-02-12 Thu 16:00' to ISO 8601."""
|
"""Convert an org timestamp body like '2026-02-12 Thu 16:00' to ISO 8601."""
|
||||||
m = re.match(
|
m = re.match(
|
||||||
@ -712,10 +789,18 @@ def build_index_page(
|
|||||||
"</form>"
|
"</form>"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
capture_form = (
|
||||||
|
"<form class='capture-form' method='post' action='/capture'>"
|
||||||
|
f"<input type='hidden' name='file' value='{html.escape(selected.relative_path, quote=True)}'>"
|
||||||
|
"<input class='capture-input' type='text' name='text' placeholder='Capture a task\u2026' autocomplete='off'>"
|
||||||
|
"<button class='capture-btn' type='submit'>Capture</button>"
|
||||||
|
"</form>"
|
||||||
|
)
|
||||||
body = (
|
body = (
|
||||||
f"<h2>{html.escape(selected.relative_path)}</h2>"
|
f"<h2>{html.escape(selected.relative_path)}</h2>"
|
||||||
f"<div class='toolbar'>{mode_toggle}</div>"
|
f"<div class='toolbar'>{mode_toggle}</div>"
|
||||||
f"{status_html}"
|
f"{status_html}"
|
||||||
|
f"{capture_form}"
|
||||||
f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}</article>"
|
f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}</article>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -781,6 +866,10 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
self.serve_static(parsed.path)
|
self.serve_static(parsed.path)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/sw.js":
|
||||||
|
self.serve_service_worker()
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path != "/":
|
if parsed.path != "/":
|
||||||
self.send_error(404, "Not found")
|
self.send_error(404, "Not found")
|
||||||
return
|
return
|
||||||
@ -810,6 +899,9 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
elif error == "missing":
|
elif error == "missing":
|
||||||
status_message = "Select a valid .org file first."
|
status_message = "Select a valid .org file first."
|
||||||
status_level = "error"
|
status_level = "error"
|
||||||
|
elif error == "empty":
|
||||||
|
status_message = "Enter some text to capture."
|
||||||
|
status_level = "error"
|
||||||
elif error == "write":
|
elif error == "write":
|
||||||
status_message = "Could not write this file."
|
status_message = "Could not write this file."
|
||||||
status_level = "error"
|
status_level = "error"
|
||||||
@ -836,6 +928,9 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
if parsed.path == "/webhook":
|
if parsed.path == "/webhook":
|
||||||
self.handle_webhook()
|
self.handle_webhook()
|
||||||
return
|
return
|
||||||
|
if parsed.path == "/capture":
|
||||||
|
self.handle_capture()
|
||||||
|
return
|
||||||
if parsed.path != "/edit":
|
if parsed.path != "/edit":
|
||||||
self.send_error(404, "Not found")
|
self.send_error(404, "Not found")
|
||||||
return
|
return
|
||||||
@ -860,6 +955,31 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
|
|
||||||
self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"})
|
self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"})
|
||||||
|
|
||||||
|
def handle_capture(self) -> None:
|
||||||
|
content_length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||||
|
params = parse_qs(body)
|
||||||
|
selected_path = params.get("file", [None])[0]
|
||||||
|
text = params.get("text", [""])[0].strip()
|
||||||
|
|
||||||
|
org_files = scan_org_files(base_dir)
|
||||||
|
selected = find_org_file(org_files, selected_path)
|
||||||
|
if selected is None:
|
||||||
|
self.redirect_with_query("/", {"error": "missing"})
|
||||||
|
return
|
||||||
|
if not text:
|
||||||
|
self.redirect_with_query("/", {"file": selected.relative_path, "error": "empty"})
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = add_to_inbox(selected.content, text)
|
||||||
|
try:
|
||||||
|
selected.path.write_text(updated, encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
self.redirect_with_query("/", {"file": selected.relative_path, "error": "write"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self.redirect_with_query("/", {"file": selected.relative_path, "saved": "1"})
|
||||||
|
|
||||||
def handle_webhook(self) -> None:
|
def handle_webhook(self) -> None:
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
self.send_json_response(400, {"error": "No webhook_url configured"})
|
self.send_json_response(400, {"error": "No webhook_url configured"})
|
||||||
@ -879,13 +999,18 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
self.send_json_response(400, {"error": "Missing file or heading"})
|
self.send_json_response(400, {"error": "Missing file or heading"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
state = payload.get("state", "STRT")
|
||||||
|
if state not in ("STRT", "DONE"):
|
||||||
|
self.send_json_response(400, {"error": "Invalid state"})
|
||||||
|
return
|
||||||
|
|
||||||
org_files = scan_org_files(base_dir)
|
org_files = scan_org_files(base_dir)
|
||||||
org_file = find_org_file(org_files, file_path)
|
org_file = find_org_file(org_files, file_path)
|
||||||
if org_file is None:
|
if org_file is None:
|
||||||
self.send_json_response(404, {"error": "File not found"})
|
self.send_json_response(404, {"error": "File not found"})
|
||||||
return
|
return
|
||||||
|
|
||||||
data = extract_heading_data(org_file.content, heading_text, file_id=org_file.file_id or "")
|
data = extract_heading_data(org_file.content, heading_text, file_id=org_file.file_id or "", state=state)
|
||||||
if data is None:
|
if data is None:
|
||||||
self.send_json_response(404, {"error": "Heading not found"})
|
self.send_json_response(404, {"error": "Heading not found"})
|
||||||
return
|
return
|
||||||
@ -896,11 +1021,24 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
if webhook_token:
|
if webhook_token:
|
||||||
headers["Authorization"] = f"Token {webhook_token}"
|
headers["Authorization"] = f"Token {webhook_token}"
|
||||||
|
|
||||||
|
new_keyword = "DONE" if state == "DONE" else "STRT"
|
||||||
|
updated_content = update_heading_keyword(org_file.content, heading_text, new_keyword)
|
||||||
|
file_updated = False
|
||||||
|
if updated_content is not None:
|
||||||
|
try:
|
||||||
|
org_file.path.write_text(updated_content, encoding="utf-8")
|
||||||
|
file_updated = True
|
||||||
|
except OSError:
|
||||||
|
file_updated = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req = Request(webhook_url, data=encoded_data, headers=headers, method="POST")
|
req = Request(webhook_url, data=encoded_data, headers=headers, method="POST")
|
||||||
with urlopen(req, timeout=10) as resp:
|
with urlopen(req, timeout=10) as resp:
|
||||||
resp_body = resp.read().decode("utf-8", errors="replace")
|
resp_body = resp.read().decode("utf-8", errors="replace")
|
||||||
self.send_json_response(200, {"ok": True, "response": resp_body})
|
result: dict = {"ok": True, "response": resp_body}
|
||||||
|
if not file_updated:
|
||||||
|
result["error"] = "Could not update heading state in file"
|
||||||
|
self.send_json_response(200, result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.send_json_response(502, {"error": f"Webhook failed: {exc}"})
|
self.send_json_response(502, {"error": f"Webhook failed: {exc}"})
|
||||||
|
|
||||||
@ -937,6 +1075,19 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(data)
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def serve_service_worker(self) -> None:
|
||||||
|
candidate = STATIC_DIR / "sw.js"
|
||||||
|
if not candidate.is_file():
|
||||||
|
self.send_error(404, "Not found")
|
||||||
|
return
|
||||||
|
data = candidate.read_bytes()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/javascript")
|
||||||
|
self.send_header("Service-Worker-Allowed", "/")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args) -> None:
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 693 B |
BIN
static/icon-512-maskable.png
Normal file
BIN
static/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
29
static/manifest.webmanifest
Normal file
29
static/manifest.webmanifest
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "Org Web Adapter",
|
||||||
|
"short_name": "OrgWeb",
|
||||||
|
"description": "Browse, edit, and capture notes in your Org files.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0f1720",
|
||||||
|
"theme_color": "#0f1720",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -386,6 +386,43 @@ main h2 {
|
|||||||
filter: brightness(1.08);
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.capture-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.org-content {
|
.org-content {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@ -618,9 +655,15 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
|
order: 2;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: none;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -631,6 +674,7 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backlinks-pane {
|
.backlinks-pane {
|
||||||
|
order: 3;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|||||||
53
static/sw.js
Normal file
53
static/sw.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const CACHE = "orgweb-v1";
|
||||||
|
const PRECACHE = [
|
||||||
|
"/",
|
||||||
|
"/static/style.css",
|
||||||
|
"/static/manifest.webmanifest",
|
||||||
|
"/static/icon-192.png",
|
||||||
|
"/static/icon-512.png",
|
||||||
|
"/static/icon-512-maskable.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE)
|
||||||
|
.then((cache) => cache.addAll(PRECACHE))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.origin !== location.origin) return;
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).catch(() => caches.match("/"))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(
|
||||||
|
(cached) =>
|
||||||
|
cached ||
|
||||||
|
fetch(event.request).then((resp) => {
|
||||||
|
const copy = resp.clone();
|
||||||
|
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
|
||||||
|
return resp;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -4,6 +4,13 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Org Web Adapter</title>
|
<title>Org Web Adapter</title>
|
||||||
|
<meta name="theme-color" content="#0f1720">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
|
<link rel="icon" type="image/png" href="/static/icon-192.png">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -316,12 +323,14 @@
|
|||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const file = btn.getAttribute("data-file");
|
const file = btn.getAttribute("data-file");
|
||||||
const heading = btn.getAttribute("data-heading");
|
const heading = btn.getAttribute("data-heading");
|
||||||
|
const state = btn.getAttribute("data-state") || "STRT";
|
||||||
|
const label = btn.textContent.trim();
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Sending\u2026";
|
btn.textContent = "Sending\u2026";
|
||||||
fetch("/webhook", {
|
fetch("/webhook", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ file: file, heading: heading }),
|
body: JSON.stringify({ file: file, heading: heading, state: state }),
|
||||||
})
|
})
|
||||||
.then(function (resp) { return resp.json(); })
|
.then(function (resp) { return resp.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
@ -340,12 +349,19 @@
|
|||||||
.finally(function () {
|
.finally(function () {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Start work";
|
btn.textContent = label;
|
||||||
btn.classList.remove("webhook-sent", "webhook-error");
|
btn.classList.remove("webhook-sent", "webhook-error");
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user