feat: sync saved mobile search views (Closes #821)
This commit is contained in:
parent
6314efb82b
commit
19772b5797
|
|
@ -68,6 +68,12 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||||
|
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||||
|
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||||
|
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||||
|
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||||
|
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||||
|
`.stackchain-state/saved-searches.sqlite3` path.
|
||||||
Search previews also
|
Search previews also
|
||||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||||
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||||
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
|
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
|
||||||
|
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||||
|
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
|
||||||
|
.saved-search-header { justify-content:space-between; }
|
||||||
|
.saved-search-create { margin-top:6px; }
|
||||||
|
.saved-search-create input { flex:1; min-width:0; }
|
||||||
|
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
|
||||||
|
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
|
||||||
|
.saved-search-open { flex:1; min-width:0; text-align:left; }
|
||||||
|
.saved-search-action { min-height:44px; }
|
||||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||||
|
|
@ -733,6 +742,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
|
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
|
||||||
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
|
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
|
||||||
#cmd-search-repository { min-height:44px; width:100%; }
|
#cmd-search-repository { min-height:44px; width:100%; }
|
||||||
|
.saved-search-create { align-items:stretch; }
|
||||||
|
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
|
||||||
|
.saved-search-row { align-items:stretch; }
|
||||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
|
|
|
||||||
|
|
@ -4613,6 +4613,9 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
taskOverlayHistory.start();
|
taskOverlayHistory.start();
|
||||||
|
createSavedSearches.mount(
|
||||||
|
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
|
||||||
|
).load();
|
||||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||||
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,18 @@
|
||||||
<datalist id="cmd-search-repositories"></datalist>
|
<datalist id="cmd-search-repositories"></datalist>
|
||||||
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
|
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<section class="saved-searches" aria-labelledby="saved-searches-heading">
|
||||||
|
<div class="saved-search-header">
|
||||||
|
<strong id="saved-searches-heading">Saved searches</strong>
|
||||||
|
<span id="saved-search-status" class="small" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<div class="saved-search-create">
|
||||||
|
<label for="saved-search-name" class="sr-only">Saved search name</label>
|
||||||
|
<input id="saved-search-name" type="text" maxlength="60" autocomplete="off" placeholder="Name this search" />
|
||||||
|
<button id="save-current-search" class="saved-search-action" type="button">Save search</button>
|
||||||
|
</div>
|
||||||
|
<div id="saved-search-list" class="saved-search-list" aria-label="Saved searches"></div>
|
||||||
|
</section>
|
||||||
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
|
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
|
||||||
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
|
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
|
||||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||||
|
|
@ -1107,6 +1119,7 @@
|
||||||
<script src="static/security-center.js"></script>
|
<script src="static/security-center.js"></script>
|
||||||
<script src="static/markdown.js"></script>
|
<script src="static/markdown.js"></script>
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
|
<script src="static/saved-searches.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
<script src="static/search-defer.js"></script>
|
<script src="static/search-defer.js"></script>
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
|
|
|
||||||
159
frontend/saved-searches.js
Normal file
159
frontend/saved-searches.js
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const createSavedSearches = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
|
||||||
|
if (root) root.createSavedSearches = createSavedSearches;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
function createSavedSearches(options) {
|
||||||
|
const fetchJson = options.fetchJson;
|
||||||
|
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
|
||||||
|
const onOpen = options.onOpen;
|
||||||
|
const onState = options.onState || (() => {});
|
||||||
|
let current = { revision:0, views:[] };
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(status, message) {
|
||||||
|
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(name, search, id) {
|
||||||
|
const cleanName = String(name || '').trim();
|
||||||
|
const query = String(search?.query || '').trim();
|
||||||
|
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
|
||||||
|
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
|
||||||
|
return {
|
||||||
|
id, name:cleanName, query,
|
||||||
|
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
|
||||||
|
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
|
||||||
|
repository:String(search?.repository || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replace(views) {
|
||||||
|
publish('saving');
|
||||||
|
try {
|
||||||
|
current = await fetchJson('api/v1/saved-searches', {
|
||||||
|
method:'PUT', headers:{ 'Content-Type':'application/json' },
|
||||||
|
body:JSON.stringify({ revision:current.revision, views }),
|
||||||
|
});
|
||||||
|
publish('ready');
|
||||||
|
return snapshot();
|
||||||
|
} catch (error) {
|
||||||
|
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||||
|
if (conflict) {
|
||||||
|
current = conflict;
|
||||||
|
publish('conflict', 'Saved searches changed on another device.');
|
||||||
|
} else {
|
||||||
|
publish('error', 'Saved searches could not sync. Search still works.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async load() {
|
||||||
|
publish('loading');
|
||||||
|
try {
|
||||||
|
current = await fetchJson('api/v1/saved-searches');
|
||||||
|
publish('ready');
|
||||||
|
} catch (error) {
|
||||||
|
publish('error', 'Saved searches could not load. Search still works.');
|
||||||
|
}
|
||||||
|
return snapshot();
|
||||||
|
},
|
||||||
|
save(name, search) {
|
||||||
|
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
|
||||||
|
const created = normalizeSearch(name, search, createId());
|
||||||
|
return replace(current.views.concat(created));
|
||||||
|
},
|
||||||
|
rename(id, name) {
|
||||||
|
const existing = current.views.find(view => view.id === id);
|
||||||
|
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
|
||||||
|
const changed = normalizeSearch(name, existing, existing.id);
|
||||||
|
return replace(current.views.map(view => view.id === id ? changed : view));
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
|
||||||
|
return replace(current.views.filter(view => view.id !== id));
|
||||||
|
},
|
||||||
|
open(id) {
|
||||||
|
const selected = current.views.find(view => view.id === id);
|
||||||
|
if (selected) onOpen({ ...selected });
|
||||||
|
},
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
|
||||||
|
const query = selector => document.querySelector(selector);
|
||||||
|
const input = query('#cmd-input');
|
||||||
|
const request = async (url, init) => {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
|
||||||
|
error.status = response.status;
|
||||||
|
error.payload = payload;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
let controller;
|
||||||
|
function render(state) {
|
||||||
|
const list = query('#saved-search-list');
|
||||||
|
const status = query('#saved-search-status');
|
||||||
|
list.replaceChildren();
|
||||||
|
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
|
||||||
|
conflict:state.message, error:state.message})[state.status] || '';
|
||||||
|
state.views.forEach(view => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'saved-search-row';
|
||||||
|
const open = document.createElement('button');
|
||||||
|
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
|
||||||
|
open.textContent = view.name; open.title = view.query;
|
||||||
|
open.addEventListener('click', () => controller.open(view.id));
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'saved-search-row-actions';
|
||||||
|
for (const action of ['Rename', 'Delete']) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
|
||||||
|
button.setAttribute('aria-label', action + ' ' + view.name);
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
if (action === 'Rename') {
|
||||||
|
const name = globalThis.prompt('Rename saved search', view.name);
|
||||||
|
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
|
||||||
|
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
|
||||||
|
await controller.remove(view.id).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.append(button);
|
||||||
|
}
|
||||||
|
row.append(open, actions); list.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
|
||||||
|
search.setQuery('');
|
||||||
|
input.value = view.query;
|
||||||
|
applyScope(view);
|
||||||
|
search.setQuery(view.query);
|
||||||
|
history.update({query:view.query, scope:view});
|
||||||
|
input.focus();
|
||||||
|
}});
|
||||||
|
query('#save-current-search').addEventListener('click', async () => {
|
||||||
|
const name = query('#saved-search-name').value.trim();
|
||||||
|
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
|
||||||
|
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
|
||||||
|
if (!name || current.query.length < 2) {
|
||||||
|
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
|
||||||
|
.catch(error => { query('#saved-search-status').textContent = error.message; });
|
||||||
|
});
|
||||||
|
return controller;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createSavedSearches;
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,7 @@ const SHELL = [
|
||||||
BASE + 'static/security-center.js',
|
BASE + 'static/security-center.js',
|
||||||
BASE + 'static/markdown.js',
|
BASE + 'static/markdown.js',
|
||||||
BASE + 'static/commands.js',
|
BASE + 'static/commands.js',
|
||||||
|
BASE + 'static/saved-searches.js',
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
BASE + 'static/search-defer.js',
|
BASE + 'static/search-defer.js',
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
"static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/assign-and-start.js", "static/queue-today.js",
|
"static/assign-and-start.js", "static/queue-today.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
|
|
||||||
66
src/main.py
66
src/main.py
|
|
@ -57,6 +57,7 @@ from src.push_notifications import (
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.later_store import LaterStore
|
from src.later_store import LaterStore
|
||||||
|
|
@ -617,6 +618,20 @@ class LaterOperationBatch(BaseModel):
|
||||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchView(BaseModel):
|
||||||
|
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
|
||||||
|
name: str = Field(min_length=1, max_length=60)
|
||||||
|
query: str = Field(min_length=2, max_length=200)
|
||||||
|
kind: Literal["all", "issue", "pull"] = "all"
|
||||||
|
state: Literal["all", "open", "closed"] = "all"
|
||||||
|
repository: str = Field(default="", max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchCollection(BaseModel):
|
||||||
|
revision: int = Field(ge=0)
|
||||||
|
views: list[SavedSearchView] = Field(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
class NotificationLaterRequest(BaseModel):
|
class NotificationLaterRequest(BaseModel):
|
||||||
wake_at: str = Field(min_length=1, max_length=100)
|
wake_at: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
@ -1183,7 +1198,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(request)
|
path = dashboard_auth.application_path(request)
|
||||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -2114,6 +2129,12 @@ def _later_store() -> LaterStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_search_store() -> SavedSearchStore:
|
||||||
|
return SavedSearchStore(
|
||||||
|
os.getenv("STACKCHAIN_SAVED_SEARCH_DB", str(_state_dir / "saved-searches.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _confirmed_login() -> str:
|
async def _confirmed_login() -> str:
|
||||||
try:
|
try:
|
||||||
user = await asyncio.wait_for(
|
user = await asyncio.wait_for(
|
||||||
|
|
@ -2129,6 +2150,49 @@ async def _confirmed_login() -> str:
|
||||||
return login.strip().lower()
|
return login.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/saved-searches")
|
||||||
|
async def get_saved_searches(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Saved Search synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/saved-searches")
|
||||||
|
async def replace_saved_searches(payload: SavedSearchCollection):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_saved_search_store().replace,
|
||||||
|
login,
|
||||||
|
payload.revision,
|
||||||
|
[view.model_dump() for view in payload.views],
|
||||||
|
)
|
||||||
|
except SavedSearchConflict as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Saved searches changed on another device.",
|
||||||
|
"snapshot": exc.snapshot,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Saved Search synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/today")
|
@app.get("/api/v1/today")
|
||||||
async def get_today_plan():
|
async def get_today_plan():
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
130
src/saved_search_store.py
Normal file
130
src/saved_search_store.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Durable, account-scoped saved Search views."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchConflict(ValueError):
|
||||||
|
"""Raised when a client attempts to replace a stale collection."""
|
||||||
|
|
||||||
|
def __init__(self, snapshot: dict):
|
||||||
|
super().__init__("saved searches changed on another device")
|
||||||
|
self.snapshot = snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchStore:
|
||||||
|
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.limit = limit
|
||||||
|
self.timeout = timeout
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
views TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login(login: str) -> str:
|
||||||
|
normalized = login.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot(row) -> dict:
|
||||||
|
return {"revision": 0, "views": []} if row is None else {
|
||||||
|
"revision": int(row[0]), "views": json.loads(row[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||||
|
(self._login(login),),
|
||||||
|
).fetchone()
|
||||||
|
return self._snapshot(row)
|
||||||
|
|
||||||
|
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||||
|
if not isinstance(views, list):
|
||||||
|
raise ValueError("views must be a list")
|
||||||
|
if len(views) > self.limit:
|
||||||
|
raise ValueError(f"saved searches are limited to {self.limit}")
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
for raw in views:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("saved search must be an object")
|
||||||
|
view_id = raw.get("id")
|
||||||
|
if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
|
||||||
|
raise ValueError("id is invalid")
|
||||||
|
if view_id in seen:
|
||||||
|
raise ValueError("saved search ids must be unique")
|
||||||
|
name = raw.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise ValueError("name is required")
|
||||||
|
name = name.strip()
|
||||||
|
if len(name) > 60:
|
||||||
|
raise ValueError("name must be at most 60 characters")
|
||||||
|
query = raw.get("query")
|
||||||
|
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
|
||||||
|
raise ValueError("query must be between 2 and 200 characters")
|
||||||
|
kind = raw.get("kind", "all")
|
||||||
|
state = raw.get("state", "all")
|
||||||
|
if kind not in {"all", "issue", "pull"}:
|
||||||
|
raise ValueError("kind is invalid")
|
||||||
|
if state not in {"all", "open", "closed"}:
|
||||||
|
raise ValueError("state is invalid")
|
||||||
|
repository = raw.get("repository", "")
|
||||||
|
if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
|
||||||
|
raise ValueError("repository is invalid")
|
||||||
|
seen.add(view_id)
|
||||||
|
normalized.append({
|
||||||
|
"id": view_id,
|
||||||
|
"name": name,
|
||||||
|
"query": query.strip(),
|
||||||
|
"kind": kind,
|
||||||
|
"state": state,
|
||||||
|
"repository": repository,
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||||
|
raise ValueError("revision is invalid")
|
||||||
|
normalized = self._normalize(views)
|
||||||
|
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
current = self._snapshot(row)
|
||||||
|
if current["revision"] != expected_revision:
|
||||||
|
raise SavedSearchConflict(current)
|
||||||
|
revision = expected_revision + 1
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
|
||||||
|
(login, revision, serialized),
|
||||||
|
)
|
||||||
|
return {"revision": revision, "views": normalized}
|
||||||
|
|
@ -74,8 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||||
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
|
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||||
assert len(first.runtime_gzip_bytes) <= 98 * 1024
|
assert len(first.runtime_gzip_bytes) <= 99 * 1024
|
||||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||||
|
|
|
||||||
95
tests/test_saved_search_store.py
Normal file
95
tests/test_saved_search_store.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
|
|
||||||
|
|
||||||
|
def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||||
|
return {
|
||||||
|
"id": view_id,
|
||||||
|
"name": name,
|
||||||
|
"query": query,
|
||||||
|
"kind": scope.get("kind", "issue"),
|
||||||
|
"state": scope.get("state", "open"),
|
||||||
|
"repository": scope.get("repository", "stackchain/stackchain-dashboard"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||||
|
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
|
||||||
|
|
||||||
|
created = store.replace(" Timmy ", 0, [view(), view("reviews", "My reviews", "review", kind="pull")])
|
||||||
|
|
||||||
|
assert created == {"revision": 1, "views": [view(), view("reviews", "My reviews", "review", kind="pull")]}
|
||||||
|
assert store.get("timmy") == created
|
||||||
|
assert store.get("alexander") == {"revision": 0, "views": []}
|
||||||
|
|
||||||
|
with pytest.raises(SavedSearchConflict) as conflict:
|
||||||
|
store.replace("timmy", 0, [view(name="Stale overwrite")])
|
||||||
|
assert conflict.value.snapshot == created
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_validate_and_bound_the_synced_collection(tmp_path):
|
||||||
|
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3", limit=2)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="limited to 2"):
|
||||||
|
store.replace("timmy", 0, [view("one"), view("two"), view("three")])
|
||||||
|
with pytest.raises(ValueError, match="unique"):
|
||||||
|
store.replace("timmy", 0, [view("same"), view("same")])
|
||||||
|
|
||||||
|
invalid = [
|
||||||
|
({**view(), "name": " "}, "name is required"),
|
||||||
|
({**view(), "query": "x"}, "query must be between"),
|
||||||
|
({**view(), "kind": "commit"}, "kind is invalid"),
|
||||||
|
({**view(), "state": "merged"}, "state is invalid"),
|
||||||
|
({**view(), "repository": "not-a-repository"}, "repository is invalid"),
|
||||||
|
]
|
||||||
|
for candidate, message in invalid:
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
store.replace("timmy", 0, [candidate])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_saved_search_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SAVED_SEARCH_DB", str(tmp_path / "searches.sqlite3"))
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "Timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
|
forbidden = await client.put(
|
||||||
|
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}
|
||||||
|
)
|
||||||
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
|
saved = await client.put(
|
||||||
|
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}, headers=headers
|
||||||
|
)
|
||||||
|
stale = await client.put(
|
||||||
|
"/api/v1/saved-searches",
|
||||||
|
json={"revision": 0, "views": [view(name="Overwrite")]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
fetched = await client.get("/api/v1/saved-searches")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json() == {"revision": 1, "views": [view()]}
|
||||||
|
assert stale.status_code == 409
|
||||||
|
assert stale.json()["detail"] == {
|
||||||
|
"message": "Saved searches changed on another device.",
|
||||||
|
"snapshot": saved.json(),
|
||||||
|
}
|
||||||
|
assert fetched.json() == saved.json()
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
105
tests/test_saved_searches.py
Normal file
105
tests/test_saved_searches.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SAVED_SEARCHES = Path(__file__).parents[1] / "frontend" / "saved-searches.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script):
|
||||||
|
return json.loads(
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_search_controller_loads_saves_opens_renames_and_deletes():
|
||||||
|
script = f"""
|
||||||
|
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
|
||||||
|
const requests=[]; const opened=[]; const states=[];
|
||||||
|
let remote={{revision:0,views:[]}}; let sequence=0;
|
||||||
|
const controller=createSavedSearches({{
|
||||||
|
fetchJson:async (_url,options={{}})=>{{
|
||||||
|
requests.push(options.method||'GET');
|
||||||
|
if(!options.method)return remote;
|
||||||
|
const body=JSON.parse(options.body);remote={{revision:remote.revision+1,views:body.views}};return remote;
|
||||||
|
}},
|
||||||
|
createId:()=> 'view-'+(++sequence),
|
||||||
|
onOpen:view=>opened.push(view),
|
||||||
|
onState:state=>states.push(state),
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await controller.load();
|
||||||
|
await controller.save('Release queue',{{query:'mobile',kind:'issue',state:'open',repository:'stackchain/dashboard'}});
|
||||||
|
controller.open('view-1');
|
||||||
|
await controller.rename('view-1','Morning release queue');
|
||||||
|
await controller.remove('view-1');
|
||||||
|
process.stdout.write(JSON.stringify({{requests,opened,snapshot:controller.snapshot(),statuses:states.map(x=>x.status)}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"requests": ["GET", "PUT", "PUT", "PUT"],
|
||||||
|
"opened": [{
|
||||||
|
"id": "view-1",
|
||||||
|
"name": "Release queue",
|
||||||
|
"query": "mobile",
|
||||||
|
"kind": "issue",
|
||||||
|
"state": "open",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
}],
|
||||||
|
"snapshot": {"revision": 3, "views": []},
|
||||||
|
"statuses": ["loading", "ready", "saving", "ready", "saving", "ready", "saving", "ready"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_search_controller_adopts_conflict_and_keeps_search_usable_on_failure():
|
||||||
|
script = f"""
|
||||||
|
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
|
||||||
|
const states=[];let mode='conflict';
|
||||||
|
const server={{revision:4,views:[{{id:'remote',name:'Remote',query:'review',kind:'pull',state:'open',repository:''}}]}};
|
||||||
|
const controller=createSavedSearches({{
|
||||||
|
fetchJson:async (_url,options={{}})=>{{
|
||||||
|
if(!options.method)return {{revision:3,views:[]}};
|
||||||
|
if(mode==='conflict'){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:server}}}};throw error;}}
|
||||||
|
throw new Error('offline');
|
||||||
|
}},createId:()=> 'local',onOpen:()=>{{}},onState:state=>states.push([state.status,state.message||'']),
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await controller.load();
|
||||||
|
try{{await controller.save('Local',{{query:'mobile',kind:'all',state:'all'}})}}catch(_error){{}}
|
||||||
|
mode='offline';
|
||||||
|
try{{await controller.remove('remote')}}catch(_error){{}}
|
||||||
|
process.stdout.write(JSON.stringify({{snapshot:controller.snapshot(),states}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
result = run_node(script)
|
||||||
|
assert result["snapshot"] == {
|
||||||
|
"revision": 4,
|
||||||
|
"views": [{
|
||||||
|
"id": "remote", "name": "Remote", "query": "review",
|
||||||
|
"kind": "pull", "state": "open", "repository": "",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
assert result["states"][-1] == ["error", "Saved searches could not sync. Search still works."]
|
||||||
|
assert ["conflict", "Saved searches changed on another device."] in result["states"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_renders_synced_saved_view_controls_and_runtime_wiring():
|
||||||
|
from tests.dashboard_bundle import dashboard_bundle_text
|
||||||
|
|
||||||
|
html = dashboard_bundle_text()
|
||||||
|
css = (SAVED_SEARCHES.parent / "dashboard.css").read_text()
|
||||||
|
dashboard = (SAVED_SEARCHES.parent / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert '<section class="saved-searches" aria-labelledby="saved-searches-heading">' in html
|
||||||
|
assert 'id="saved-search-name"' in html
|
||||||
|
assert 'id="save-current-search"' in html
|
||||||
|
assert 'id="saved-search-list"' in html
|
||||||
|
assert 'id="saved-search-status"' in html
|
||||||
|
assert "createSavedSearches.mount(" in dashboard
|
||||||
|
saved_searches = SAVED_SEARCHES.read_text()
|
||||||
|
assert "search.setQuery('')" in saved_searches
|
||||||
|
assert "applyScope(view)" in saved_searches
|
||||||
|
assert "search.setQuery(view.query)" in saved_searches
|
||||||
|
assert ").load();" in dashboard
|
||||||
|
assert "static/saved-searches.js" in (SAVED_SEARCHES.parent.parent / "src" / "frontend_bundle.py").read_text()
|
||||||
|
assert ".saved-search-action { min-height:44px;" in css
|
||||||
|
|
@ -787,6 +787,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/security-center.js",
|
"/dashboard/static/security-center.js",
|
||||||
"/dashboard/static/markdown.js",
|
"/dashboard/static/markdown.js",
|
||||||
"/dashboard/static/commands.js",
|
"/dashboard/static/commands.js",
|
||||||
|
"/dashboard/static/saved-searches.js",
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
"/dashboard/static/search-defer.js",
|
"/dashboard/static/search-defer.js",
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user