Capture shared mobile content as a planned issue #224
|
|
@ -8,8 +8,20 @@ function newIssueOperationId() {
|
|||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function normalizeSharedContent(value = {}) {
|
||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||
const text = String(value.text || '').trim().slice(0, 9500);
|
||||
const textSummary = clean(text);
|
||||
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
|
||||
const title = (clean(value.title) || sentence).slice(0, 240);
|
||||
const url = clean(value.url).slice(0, 2000);
|
||||
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
|
|
@ -73,6 +85,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||
}
|
||||
|
||||
function pendingSharedContent() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
|
||||
return shared.title || shared.body ? shared : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptSharedContent() {
|
||||
const shared = pendingSharedContent();
|
||||
if (!shared) return loadDraft();
|
||||
const accepted = saveDraft({...loadDraft(), ...shared});
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardSharedContent() {
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* The existing issue draft remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageSharedContent(value) {
|
||||
const shared = normalizeSharedContent(value);
|
||||
if (!shared.title && !shared.body) return {status: 'empty'};
|
||||
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
|
||||
catch (_error) { /* The caller can still use the in-page share payload. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptSharedContent();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
|
|
@ -117,7 +165,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
return pending;
|
||||
}
|
||||
|
||||
return { saveDraft, loadDraft, loadLabels, loadMilestones, submit };
|
||||
return {
|
||||
saveDraft, loadDraft, loadLabels, loadMilestones, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
|
|
|
|||
BIN
frontend/icons/stackchain-192.png
Normal file
BIN
frontend/icons/stackchain-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 966 B |
BIN
frontend/icons/stackchain-512.png
Normal file
BIN
frontend/icons/stackchain-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -3,6 +3,8 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0b1220" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<title>Stackchain Dashboard</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
|
|
@ -212,6 +214,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.shared-content-actions button { min-height:44px; }
|
||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.pull-sheet.open { display:flex; }
|
||||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
|
|
@ -509,6 +514,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<h3 id="create-issue-heading">New issue</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
|
||||
<strong>You already have an unfinished issue draft.</strong>
|
||||
<span class="small">Resume it, or replace its title and description with the content shared to Stackchain.</span>
|
||||
<div class="shared-content-actions">
|
||||
<button id="resume-issue-draft" type="button">Resume existing draft</button>
|
||||
<button id="use-shared-content" type="button">Use shared content</button>
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
|
|
@ -794,6 +807,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const sharedLaunch = {
|
||||
title: shareParams.get('title') || '',
|
||||
text: shareParams.get('text') || '',
|
||||
url: shareParams.get('url') || '',
|
||||
};
|
||||
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||
const findWorkController = createFindWork({
|
||||
|
|
@ -1117,6 +1138,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
if (data.work_pagination) workPager.reset(data.work_pagination);
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
openStagedSharedContent();
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||||
|
|
@ -1818,6 +1840,25 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
}
|
||||
|
||||
function clearSharedLaunchUrl() {
|
||||
const cleanUrl = location.pathname + location.hash;
|
||||
history.replaceState({}, '', cleanUrl);
|
||||
}
|
||||
|
||||
function openStagedSharedContent() {
|
||||
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
|
||||
openCreateIssueSheet();
|
||||
if (sharedLaunchState.status === 'conflict') {
|
||||
qs('#shared-content-conflict').hidden = false;
|
||||
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
|
||||
qs('#resume-issue-draft').focus();
|
||||
sharedLaunchState = {status: 'shown'};
|
||||
return;
|
||||
}
|
||||
clearSharedLaunchUrl();
|
||||
sharedLaunchState = null;
|
||||
}
|
||||
|
||||
function openCreateIssueSheet() {
|
||||
const captureDraft = issueCapture.loadDraft();
|
||||
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||
|
|
@ -2400,6 +2441,22 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
});
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
qs('#use-shared-content').addEventListener('click', () => {
|
||||
issueCapture.acceptSharedContent();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
sharedLaunchState = null;
|
||||
clearSharedLaunchUrl();
|
||||
openCreateIssueSheet();
|
||||
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
|
||||
});
|
||||
qs('#resume-issue-draft').addEventListener('click', () => {
|
||||
issueCapture.discardSharedContent();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
sharedLaunchState = null;
|
||||
clearSharedLaunchUrl();
|
||||
qs('#create-issue-status').textContent = 'Existing draft restored.';
|
||||
qs('#create-issue-title').focus();
|
||||
});
|
||||
qs('#cancel-new-issue').addEventListener('click', () => {
|
||||
saveIssueCaptureDraft();
|
||||
closeCreateIssueSheet();
|
||||
|
|
@ -3009,6 +3066,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
});
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/service-worker.js').catch(error =>
|
||||
console.warn('Stackchain install support unavailable', error)
|
||||
);
|
||||
}
|
||||
contextPoller.start();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
|
|
|
|||
21
frontend/manifest.webmanifest
Normal file
21
frontend/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "Stackchain Dashboard",
|
||||
"short_name": "Stackchain",
|
||||
"description": "Capture, plan, and complete Gitea work from mobile.",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#07111f",
|
||||
"theme_color": "#0b1220",
|
||||
"icons": [
|
||||
{"src": "/static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
}
|
||||
}
|
||||
25
frontend/service-worker.js
Normal file
25
frontend/service-worker.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const CACHE = 'stackchain-shell-v1';
|
||||
const SHELL = ['/manifest.webmanifest', '/static/icons/stackchain-192.png', '/static/icons/stackchain-512.png'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE).map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
18
src/views.py
18
src/views.py
|
|
@ -1,12 +1,28 @@
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return DASHBOARD_FILE.read_text()
|
||||
|
||||
|
||||
@router.get("/manifest.webmanifest", response_class=FileResponse)
|
||||
async def web_app_manifest() -> FileResponse:
|
||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||
|
||||
|
||||
@router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
SERVICE_WORKER_FILE,
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -534,6 +534,20 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
|||
assert "dueDate: qs('#create-issue-due-date').value" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<link rel="manifest" href="manifest.webmanifest"' in html
|
||||
assert "issueCapture.stageSharedContent(sharedLaunch)" in html
|
||||
assert 'id="use-shared-content"' in html
|
||||
assert 'id="resume-issue-draft"' in html
|
||||
assert 'id="shared-content-conflict"' in html
|
||||
assert '.shared-content-actions button { min-height:44px;' in html
|
||||
assert "history.replaceState({}, '', cleanUrl)" in html
|
||||
assert "navigator.serviceWorker.register('/service-worker.js')" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
||||
html = await dashboard()
|
||||
|
|
@ -1232,6 +1246,105 @@ first.submit(draft).catch(() => {{
|
|||
assert output["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_normalizes_shared_mobile_content_without_repeating_source_url():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const normalized = createIssueCapture.normalizeSharedContent({{
|
||||
title: ' Production alert ',
|
||||
text: 'Latency crossed the threshold https://status.example/incidents/42',
|
||||
url: 'https://status.example/incidents/42',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(normalized));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"title": "Production alert",
|
||||
"body": "Latency crossed the threshold https://status.example/incidents/42",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_suggests_a_title_when_mobile_share_only_contains_text():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
process.stdout.write(JSON.stringify(createIssueCapture.normalizeSharedContent({{
|
||||
text: 'Investigate checkout latency before the release window opens. More diagnostic context follows.'
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"title": "Investigate checkout latency before the release window opens.",
|
||||
"body": "Investigate checkout latency before the release window opens. More diagnostic context follows.",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_keeps_existing_draft_until_shared_content_is_accepted():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
||||
capture.saveDraft({{
|
||||
repository:'stackchain/api', title:'Existing draft', body:'Keep me', labelIds:[3], milestoneId:9,
|
||||
}});
|
||||
const staged = capture.stageSharedContent({{title:'Shared alert', text:'Investigate', url:'https://status.example/42'}});
|
||||
const before = capture.loadDraft();
|
||||
capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
||||
const pendingAfterReload = capture.pendingSharedContent();
|
||||
const accepted = capture.acceptSharedContent();
|
||||
process.stdout.write(JSON.stringify({{staged, before, pendingAfterReload, accepted, pending:capture.pendingSharedContent()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"staged": {"status": "conflict"},
|
||||
"before": {
|
||||
"repository": "stackchain/api", "title": "Existing draft", "body": "Keep me",
|
||||
"labelIds": [3], "milestoneId": 9,
|
||||
},
|
||||
"pendingAfterReload": {
|
||||
"title": "Shared alert", "body": "Investigate\n\nhttps://status.example/42",
|
||||
},
|
||||
"accepted": {
|
||||
"repository": "stackchain/api", "title": "Shared alert",
|
||||
"body": "Investigate\n\nhttps://status.example/42", "labelIds": [3], "milestoneId": 9,
|
||||
},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_can_resume_existing_draft_and_discard_staged_share():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key)}};
|
||||
const capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Existing', body:'Keep', labelIds:[]}});
|
||||
capture.stageSharedContent({{title:'Incoming', text:'Replace'}});
|
||||
capture.discardSharedContent();
|
||||
process.stdout.write(JSON.stringify({{draft:capture.loadDraft(), pending:capture.pendingSharedContent()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"draft": {"repository": "stackchain/api", "title": "Existing", "body": "Keep", "labelIds": []},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_loads_repository_labels_with_priorities_first():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
|
|
|||
|
|
@ -61,4 +61,24 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
|
|||
response = await client.get("/api/v1/work/unknown?page=2")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
worker = await client.get("/service-worker.js")
|
||||
|
||||
assert manifest.status_code == 200
|
||||
assert manifest.headers["content-type"].startswith("application/manifest+json")
|
||||
assert manifest.json()["share_target"] == {
|
||||
"action": "/", "method": "GET", "enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"},
|
||||
}
|
||||
assert worker.status_code == 200
|
||||
assert worker.headers["content-type"].startswith("application/javascript")
|
||||
assert worker.headers["service-worker-allowed"] == "/"
|
||||
assert "request.url.includes('/api/')" in worker.text
|
||||
assert "request.method !== 'GET'" in worker.text
|
||||
Loading…
Reference in New Issue
Block a user