Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,7 @@ def _prks_merge_bibtex_export_fields_patch(
# whole abstract, the bibliographic block (journal/volume/issue/pages/isbn/doi),
# or the timestamps a card never shows. Carrying the full abstract measured at
# ~59.5% of the /api/works payload while only #/progress reads it -- and only
# its first 100 characters. See docs/agent-rules/offline-pwa.md,
# "Offline browse catalogs".
# its first 100 characters. See docs/agent-rules/offline-browse-protocol.md.
_PRKS_WORK_BROWSE_COLUMNS: Tuple[str, ...] = (
"id",
"title",
Expand Down
6 changes: 3 additions & 3 deletions frontend/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ function prksMarkConceptsDomainChanged() {
* successful call to one of those helpers invalidates the whole Positions
* domain rather than working out which Positions were actually affected.
* Independent of the Concepts domain by construction -- see
* docs/agent-rules/offline-pwa.md "Offline coherence domains".
* docs/agent-rules/offline-entity-coherence.md.
*/
function prksMarkPositionsDomainChanged() {
if (typeof prksOfflineMarkPositionsChanged !== 'function') return null;
Expand Down Expand Up @@ -1146,7 +1146,7 @@ function prksMarkPlaylistsDomainChanged() {
* the sync coordinator needs ONE place to change "discard the projection" into
* "apply the pending operation to it optimistically". A scattered
* deleteList('works-browse:index') would have to be rewritten everywhere.
* See docs/agent-rules/offline-pwa.md, "Offline browse catalogs".
* See docs/agent-rules/offline-browse-protocol.md.
* ------------------------------------------------------------------------ */

/** The stable Work catalog behind #/progress, #/types and #/types/:type. */
Expand Down Expand Up @@ -1186,7 +1186,7 @@ function prksMarkWorkBrowseDisplayChanged() {
* Folder: moving a Work changes two Folder details AND both `work_count`s in
* `folders:index`, and reparenting changes the hierarchy for every ancestor.
* A cached Folder detail also embeds whole Work cards, so Work display changes
* stale it too -- see docs/agent-rules/offline-pwa.md, "Offline coherence domains".
* stale it too -- see docs/agent-rules/offline-folder-tag-coherence.md.
*/
function prksMarkFoldersDomainChanged() {
if (typeof prksOfflineMarkFoldersChanged !== 'function') return null;
Expand Down
5 changes: 2 additions & 3 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2441,7 +2441,7 @@
* Work cards through prksWorkCardHtml() and the right-panel tag list. Backend
* hierarchy rules (cycles, unique titles, count correctness) stay canonical --
* this only stops a malformed payload from being cached or rendered. See
* docs/agent-rules/offline-pwa.md, "Offline coherence domains". */
* docs/agent-rules/offline-folder-tag-coherence.md. */
function prksIsFolderCount(value) {
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
}
Expand Down Expand Up @@ -2529,8 +2529,7 @@
* endpoint happens to join in. `renderPlaylistsIndex()` uses id/title/
* item_count; `renderPlaylistDetail()` and the right-panel editor use
* id/title/description/original_url plus each item's id (-> #/works/:id),
* title, author_text and published_date. See docs/agent-rules/offline-pwa.md,
* "Offline coherence domains". */
* title, author_text and published_date. See docs/agent-rules/offline-entity-coherence.md. */
function prksIsPlaylistItemCount(value) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
Expand Down Expand Up @@ -4374,94 +4373,94 @@
* waiting on this read. When it resolves, a missed
* pending DELETE drops the projection, and metadata
* or role overlays republish onto effectiveWork only. */
void workOpsPromise.then(function (ops) {
if (stale()) return;
/* null is a failed read. Do not treat it as an empty queue. */
if (!Array.isArray(ops)) return;
workOps = ops;
if (typeof prksPendingWorkDeletions === 'function' &&
prksPendingWorkDeletions(workOps).has(workId)) {
/* The cached row may already be a ready projection.
* Clearing the entity alone leaves that projection
* for a later placement refresh to republish. */
workDeleted = true;
work = null;
if (typeof prksProjectWorkRoute === 'function' &&
typeof prksPublishWorkRouteProjection === 'function') {
const deletedProjection = prksPublishWorkRouteProjection(
ctx,
generation,
prksProjectWorkRoute({
workId: workId,
owner: { tabId: ctx.tabId, generation: generation },
availability: 'unavailable',
lifecycle: 'pending-delete',
provenance: 'cache',
work: null,
effectiveWork: null,
recordOpen: false,
})
);
if (!deletedProjection) return;
} else if (ctx.setEntity) {
ctx.setEntity('work', null);
}
if (typeof prksOfflineRenderUnavailable === 'function') {
prksOfflineRenderUnavailable(contentDiv, 'File not available offline');
}
return;
}
if (!prksApplyReadWorkOperations(workOps)) return;
if (stale()) return;
if (typeof prksProjectWorkRoute !== 'function' ||
typeof prksPublishWorkRouteProjection !== 'function' ||
!ctx.getResource) return;
const current = ctx.getResource('workRouteProjection');
if (!current || current.availability !== 'ready' || current.workId !== workId) return;
if (current.ownerTabId !== ctx.tabId || current.ownerGeneration !== generation) return;
const entity = ctx.getEntity ? ctx.getEntity('work') : null;
const base = entity && entity.id === workId ? entity : null;
if (!base) return;
let effective = base;
if (typeof prksEffectiveWorkSync === 'function') {
effective = prksEffectiveWorkSync(base);
}
if (effective && typeof prksEffectiveWorkDetailRoles === 'function') {
effective = prksEffectiveWorkDetailRoles(effective);
}
if (effective === base && current.effectiveWork === current.work) return;
if (stale()) return;
const refreshed = prksPublishWorkRouteProjection(
ctx,
generation,
prksProjectWorkRoute({
workId: workId,
owner: { tabId: ctx.tabId, generation: generation },
availability: 'ready',
lifecycle: current.lifecycle,
provenance: current.provenance,
work: base,
effectiveWork: effective,
recordOpen: false,
})
);
if (!refreshed) return;
if (refreshed.work) work = refreshed.work;
/* The first paint used the maps it already had.
* Refresh the selected panel so pending metadata
* appears, without switching Annotations to Details. */
if (stale()) return;
const ownedNow = ctx.getResource('workRouteProjection');
if (!ownedNow || ownedNow.ownerTabId !== ctx.tabId ||
ownedNow.ownerGeneration !== generation ||
ownedNow.workId !== workId ||
ownedNow.availability !== 'ready') return;
const focused = typeof prksTabContextIsFocused === 'function'
? prksTabContextIsFocused(ctx) : true;
if (!focused || typeof updatePanelContent !== 'function') return;
const panelTab = (ctx.ui && ctx.ui.rightPanelTab) || 'details';
updatePanelContent(panelTab);
});

Check notice on line 4463 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L4376-L4463

Complex Method
}
} else {
workOps = await workOpsPromise;
Expand Down Expand Up @@ -4556,48 +4555,48 @@
? prksRefreshPendingWorkFolders() : null,
typeof prksRefreshPendingWorkPlaylists === 'function'
? prksRefreshPendingWorkPlaylists() : null,
]).then(function () {
if (stale() || !work) return;
const projection = ctx.getResource
? ctx.getResource('workRouteProjection') : null;
const owned = !!(projection
&& projection.ownerTabId === ctx.tabId
&& projection.ownerGeneration === generation
&& projection.workId === work.id
&& projection.availability === 'ready');
const entity = ctx.getEntity ? ctx.getEntity('work') : null;
const samePainted = !!(entity && entity.id === work.id);
const base = (owned && samePainted) ? entity : work;
const next = prksPlacePendingWork(base);
if (!next || next === base) return;
work = next;
if (typeof prksReplaceWorkRoutePlacement === 'function') {
if (!owned) return;
const replaced = prksReplaceWorkRoutePlacement(ctx, generation, next);
if (!replaced) return;
} else if (samePainted && ctx.setEntity) {
ctx.setEntity('work', next);
} else {
return;
}
/* Re-check the owner. A late filing must not repaint
* after this generation moved on, and it must not
* replace Annotations with Details. */
if (stale()) return;
if (typeof prksReplaceWorkRoutePlacement === 'function') {
const still = ctx.getResource ? ctx.getResource('workRouteProjection') : null;
if (!still || still.ownerTabId !== ctx.tabId ||
still.ownerGeneration !== generation ||
still.workId !== workId ||
still.availability !== 'ready') return;
}
const focused = typeof prksTabContextIsFocused === 'function'
? prksTabContextIsFocused(ctx) : true;
const panelTab = (ctx.ui && ctx.ui.rightPanelTab) || 'details';
if (focused && panelTab === 'details' && typeof updatePanelContent === 'function') {
updatePanelContent(panelTab);
}
}).catch(function () { /* bookkeeping never breaks the page */ });

Check notice on line 4599 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L4558-L4599

Complex Method
}
/* Maps already in memory can overlay this paint. A successful
* queue read — including a genuinely empty one — may replace
Expand Down Expand Up @@ -5445,505 +5444,505 @@
prksBindAutosizeTextareas(document);
}

document.getElementById('save-work-btn').onclick = async () => {
// Waits before the create (People syncing, video details) can outlast
// the form: once it is discarded or reopened, this submit is void.
const createModalEl = document.getElementById('work-modal');
const createGeneration = createModalEl ? createModalEl.dataset.prksOpenGeneration : '';
const createFormStillOpen = () => !!createModalEl &&
!createModalEl.classList.contains('hidden') &&
createModalEl.dataset.prksOpenGeneration === createGeneration;
// The in-flight mark names the opening that owns it: a submit left
// over from a closed form neither blocks this one nor, when it ends,
// releases this one's busy state.
if (window.__prksWorkCreateInFlight === createGeneration) return;
const beginBusy = () => {
window.__prksWorkCreateInFlight = createGeneration;
if (typeof prksSetWorkModalCreateBusy === 'function') prksSetWorkModalCreateBusy(true);
};
const endBusy = () => {
if (window.__prksWorkCreateInFlight === createGeneration) window.__prksWorkCreateInFlight = false;
if (createFormStillOpen() && typeof prksSetWorkModalCreateBusy === 'function') {
prksSetWorkModalCreateBusy(false);
}
};
// A person quick-created a moment ago is still being written and
// added: wait for it so the Work is created with them.
// Only one started in this opening of the form: a create left over
// from a closed form must not block or refuse this one.
const workModalEl = document.getElementById('work-modal');
const pendingPerson = window.__prksUploadPersonPending &&
workModalEl &&
window.__prksUploadPersonPending.prksOpenGeneration === workModalEl.dataset.prksOpenGeneration
? window.__prksUploadPersonPending
: null;
if (pendingPerson) {
beginBusy();
let personAdded = false;
try {
personAdded = (await pendingPerson) === true;
} catch (_e) {
personAdded = false;
} finally {
endBusy();
}
// Discarded or reopened meanwhile: nothing here belongs to the
// form now on screen.
if (!createFormStillOpen()) return;
// The user asked for that person: never create the file silently
// without them. Stop here; everything they entered is kept.
if (!personAdded) {
const peopleErr = document.getElementById('upload-people-error');
if (peopleErr) {
peopleErr.textContent = 'That person could not be added, so the file was not created. Try adding them again, then create.';
peopleErr.classList.remove('hidden');
}
const statusMsg = document.getElementById('upload-status-msg');
if (statusMsg) {
statusMsg.textContent = 'Nothing was saved.';
statusMsg.classList.remove('hidden');
}
return;
}
}
// Same for a tag or playlist created from this form a moment ago: the
// Work is created with it, or not at all.
const pendingEnrichment = typeof prksPendingWorkModalQuickCreates === 'function'
? prksPendingWorkModalQuickCreates() : [];
if (pendingEnrichment.length) {
beginBusy();
let enrichmentAdded = false;
try {
enrichmentAdded = (await Promise.all(pendingEnrichment)).every(Boolean);
} finally {
endBusy();
}
if (!createFormStillOpen()) return;
if (!enrichmentAdded) {
const statusMsg = document.getElementById('upload-status-msg');
if (statusMsg) {
statusMsg.textContent =
'A tag or playlist could not be created, so the file was not created. Nothing was saved.';
statusMsg.classList.remove('hidden');
}
return;
}
}
const kindEl = document.getElementById('work-source-kind');
const sourceKind = kindEl ? String(kindEl.value || 'pdf') : 'pdf';
const fileInput = document.getElementById('work-file');
let fileBase64 = null; let fileName = null;

const pdfFileForUpload =
sourceKind !== 'video' && fileInput && fileInput.files.length > 0
? fileInput.files[0]
: sourceKind !== 'video' && window.__prksPendingUploadPdfFile instanceof File
? window.__prksPendingUploadPdfFile
: null;

const folderId = document.getElementById('work-folder-id').value;
const videoUrlEl = document.getElementById('work-video-url');
const videoChanEl = document.getElementById('work-video-channel');
const videoPubEl = document.getElementById('work-video-published-date');
const videoUrlDateEl = document.getElementById('work-video-urldate');
const videoPlaylistEl = document.getElementById('work-video-playlist-id');
const pdfSourceUrlEl = document.getElementById('work-pdf-source-url');
let sourceUrl = '';
if (sourceKind === 'video' && videoUrlEl) {
sourceUrl = String(videoUrlEl.value || '').trim();
} else if (sourceKind === 'pdf' && pdfSourceUrlEl) {
sourceUrl = String(pdfSourceUrlEl.value || '').trim();
}
const publishedDate =
sourceKind === 'video' && videoPubEl ? String(videoPubEl.value || '').trim() : '';
const publishedIso =
sourceKind === 'video' ? prksParsePublishedDateInput(publishedDate) : '';
const workDateEl = document.getElementById('work-date');
const pdfPublishedRaw =
sourceKind === 'pdf' && workDateEl ? String(workDateEl.value || '').trim() : '';
const pdfPublished =
sourceKind === 'pdf' ? prksParsePublishedDateInput(pdfPublishedRaw) : '';

if (typeof prksClearWorkModalErrors === 'function') prksClearWorkModalErrors();
let firstInvalid = null;
const setErr = (control, message, errorId) => {
if (typeof prksSetWorkModalFieldError === 'function') {
prksSetWorkModalFieldError(control, message, errorId);
}
if (!firstInvalid) firstInvalid = control;
};
if (sourceKind === 'pdf' && !pdfFileForUpload) {
setErr(
document.getElementById('upload-drop-zone'),
'Choose a PDF file.',
'work-file-error'
);
}
if (sourceKind === 'video') {
const validVideoUrl =
typeof window.prksIsValidYoutubeUrl === 'function' &&
window.prksIsValidYoutubeUrl(sourceUrl);
if (!validVideoUrl) {
setErr(videoUrlEl, 'Enter a valid YouTube URL.', 'work-video-url-error');
}
}
const folderSearchEl = document.getElementById('work-folder-search');
if (
typeof window.prksIsWorkModalFolderCommitted === 'function' &&
!window.prksIsWorkModalFolderCommitted()
) {
setErr(
folderSearchEl,
'Choose a folder from the list, create this folder, or select Uncategorized.',
'work-folder-error'
);
}
if (sourceKind === 'video' && publishedDate && !publishedIso) {
setErr(videoPubEl, 'Use dd/mm/yyyy.', 'work-video-published-date-error');
}
if (sourceKind === 'pdf' && pdfPublishedRaw && !pdfPublished) {
setErr(workDateEl, 'Use dd/mm/yyyy.', 'work-date-error');
}
if (firstInvalid) {
if (typeof prksFocusWorkModalControl === 'function') {
prksFocusWorkModalControl(firstInvalid);
} else if (firstInvalid.focus) {
firstInvalid.focus();
}
return;
}

beginBusy();

try {
if (pdfFileForUpload) {
const file = pdfFileForUpload;
fileName = file.name;
try {
await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => { fileBase64 = e.target.result.split(',')[1]; resolve(); };
reader.onerror = () => reject(reader.error || new Error('File read error'));
reader.onabort = () => reject(new Error('File read aborted'));
reader.readAsDataURL(file);
});
} catch (_readErr) {
if (!createFormStillOpen()) return;
if (typeof prksSetWorkModalFieldError === 'function') {
prksSetWorkModalFieldError(
document.getElementById('upload-drop-zone'),
'Could not read this PDF. Choose the file again.',
'work-file-error'
);
}
if (typeof prksFocusWorkModalControl === 'function') {
prksFocusWorkModalControl(document.getElementById('upload-drop-zone'));
}
return;
}
}

if (
sourceKind === 'video' &&
sourceUrl &&
typeof window.prksHandleVideoUrlInput === 'function'
) {
const last = String(window.__prksLastVideoPreviewUrl || '').trim();
if (last !== sourceUrl || !window.__prksUploadVideoMeta) {
await window.prksHandleVideoUrlInput(sourceUrl);
// Discarded or reopened during the details fetch: void.
if (!createFormStillOpen()) return;
}
}
const meta = window.__prksUploadVideoMeta && typeof window.__prksUploadVideoMeta === 'object'
? window.__prksUploadVideoMeta
: null;
const channelName =
sourceKind === 'video' && videoChanEl
? String(videoChanEl.value || '').trim()
: '';
const playlistId =
sourceKind === 'video' && videoPlaylistEl ? String(videoPlaylistEl.value || '').trim() : '';

let thumb_page = null;
if (sourceKind === 'pdf') {
const tpEl = document.getElementById('work-thumb-page');
const rawTp = tpEl ? String(tpEl.value || '').trim() : '';
if (rawTp) {
const n = parseInt(rawTp, 10);
if (Number.isFinite(n) && n >= 1) thumb_page = n;
}
}

const privNotesEl = document.getElementById('work-private-notes');
const private_notes = privNotesEl ? String(privNotesEl.value || '') : '';

const gv = (id) => {
const el = document.getElementById(id);
return el ? String(el.value || '').trim() : '';
};

const payload = {
title: document.getElementById('work-title').value,
status: document.getElementById('work-status').value,
doc_type: document.getElementById('work-doc-type')
? document.getElementById('work-doc-type').value
: 'article',
abstract: document.getElementById('work-abstract').value,
author_text:
sourceKind === 'video'
? channelName || (meta && meta.author_name ? String(meta.author_name) : "")
: "",
year: document.getElementById('work-year').value,
folder_id: folderId && folderId.trim() !== "" ? folderId : null,
file_b64: fileBase64,
file_name: fileName,
roles: Array.isArray(uploadRoles) ? uploadRoles.map(function (r) {
return {
person_id: r.person_id,
role_type: r.role_type || 'Author',
credit_name: r.credit_name || '',
};
}) : [],
source_kind: sourceKind,
source_url: sourceUrl,
thumb_url: sourceKind === 'video' && meta && meta.thumbnail_url ? String(meta.thumbnail_url) : "",
provider: sourceKind === 'video' ? "youtube" : "",
published_date: sourceKind === 'video' ? (publishedIso || null) : (pdfPublished || null),
// A YouTube Work is accessed now. The durable CREATE_WORK path
// stores what it is given, so supply the date the HTTP path and a
// source change (DATE('now'), UTC) would set, not an empty one.
urldate: sourceKind === 'video' ? new Date().toISOString().slice(0, 10) : "",
playlist_id: sourceKind === 'video' ? playlistId : "",
private_notes,
thumb_page,
};

if (sourceKind === 'pdf') {
payload.publisher = gv('work-publisher');
const locEl = document.getElementById('work-location');
payload.location = locEl ? String(locEl.value || '') : '';
payload.edition = gv('work-edition');
payload.journal = gv('work-journal');
payload.volume = gv('work-volume');
payload.issue = gv('work-issue');
payload.pages = gv('work-pages');
payload.isbn = gv('work-isbn');
payload.doi = gv('work-doi');
}
if (sourceKind === 'video') {
payload.doc_type = 'online';
}

const statusMsg = document.getElementById('upload-status-msg');
if (statusMsg) {
statusMsg.textContent = '';
statusMsg.classList.add('hidden');
}

/* Video construction is durable (CREATE_WORK). PDF binary ingestion
* stays on POST /api/works until a Blob design exists. */
if (sourceKind === 'video' && typeof prksCreateWorkDurably === 'function') {
try {
const createFields = {
title: payload.title,
status: payload.status,
doc_type: 'online',
abstract: payload.abstract || '',
author_text: payload.author_text || '',
year: payload.year || '',
published_date: payload.published_date || '',
urldate: payload.urldate || '',
private_notes: payload.private_notes || '',
thumb_url: payload.thumb_url || '',
source: { kind: 'video', url: sourceUrl },
folder_id: payload.folder_id || '',
playlist_id: payload.playlist_id || '',
roles: Array.isArray(uploadRoles) ? uploadRoles.map(function (r) {
return {
person_id: r.person_id || r.id,
role_type: r.role_type || 'Author',
credit_name: r.credit_name || '',
};
}).filter(function (r) { return r.person_id; }) : [],
};
const selectedTags = (typeof uploadTagsSelected !== 'undefined' &&
Array.isArray(uploadTagsSelected))
? uploadTagsSelected.map(function (t) {
return { id: t.id, name: t.name || '' };
}).filter(function (t) { return t.id; })
: [];
if (!createFormStillOpen()) return;
const batch = await prksCreateWorkDurably(createFields, { tags: selectedTags });
const newId = batch && batch.create && batch.create.entity_id;
if (!createFormStillOpen()) return;
closeModals();
if (newId && typeof prksNavigate === 'function') {
prksNavigate('#/works/' + encodeURIComponent(newId));
}
} catch (e) {
// A failure for a form that is gone has no one to tell.
if (!createFormStillOpen()) return;
const errText = (e && e.message) || 'Could not create the file.';
if (statusMsg) {
statusMsg.textContent = prksWorkCreateFailureText(errText);
statusMsg.classList.remove('hidden');
}
if (videoUrlEl && /url|youtube/i.test(errText)) {
if (typeof prksSetWorkModalFieldError === 'function') {
prksSetWorkModalFieldError(videoUrlEl, 'Enter a valid YouTube URL.', 'work-video-url-error');
}
if (typeof prksFocusWorkModalControl === 'function') prksFocusWorkModalControl(videoUrlEl);
}
}
return;
}

if (typeof prksOfflineGuardMutation === 'function' &&
prksOfflineGuardMutation('Adding a PDF file requires a connection to PRKS.')) {
return;
}

const peopleReady = await prksWaitForPeopleOnServer(
(payload.roles || []).map(r => r.person_id)
);
if (!createFormStillOpen()) return;
if (!peopleReady) {
if (statusMsg) {
statusMsg.textContent =
'A person on this file is still being saved. Nothing was saved; try again in a moment.';
statusMsg.classList.remove('hidden');
}
return;
}

// What this submit asked for, taken while the form is frozen: after a
// discard and reopen, the live list belongs to a different form.
const tagsToAttach = typeof uploadTagsSelected !== 'undefined' && Array.isArray(uploadTagsSelected)
? uploadTagsSelected.slice() : [];
let res;
try {
res = await prksRequest('/api/works', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (e) {
if (statusMsg && createFormStillOpen()) {
// A dropped response does not prove the create failed: the
// Work may be committed. Never invite a blind retry.
statusMsg.textContent = 'Could not confirm whether the file was saved. Check your library before trying again.';
statusMsg.classList.remove('hidden');
}
return;
}
const data = await res.json().catch(() => ({}));
// A refusal for a form that is gone has no one to tell.
if (!res.ok && !createFormStillOpen()) return;
if (res.ok && String(payload.playlist_id || '').trim()) {
// The create endpoint can attach the new video to a Playlist in the
// same canonical request, bypassing addWorkToPlaylist(). The attach
// is best-effort server-side, so invalidate whenever one was asked
// for: if it succeeded the cache was stale, and if it did not this
// costs one refetch. The new Work has no cached entity to evict.
prksMarkPlaylistsDomainChanged();
}
if (res.ok && Array.isArray(payload.roles) && payload.roles.length) {
prksMarkPersonGroupsDomainChanged();
// The Work-create endpoint can create role links in the same
// canonical request, bypassing POST /api/roles entirely -- so this
// path owes People its own invalidation.
if (typeof prksMarkPeopleDomainChanged === 'function') prksMarkPeopleDomainChanged();
}
if (res.ok && typeof prksMarkWorksBrowseChanged === 'function') {
// A new Work enters the stable catalog and the top of Recently
// added. It does NOT enter Recent: last_opened_at is still NULL,
// which is one reason these are three independent projections.
prksMarkWorksBrowseChanged();
prksMarkRecentlyAddedChanged();
}
if (res.ok && typeof prksMarkFoldersDomainChanged === 'function') {
// Unlike Playlists, folder membership is NOT optional: the create
// endpoint files every new Work into the requested folder or into
// the default "Uncategorized" one, so a folders:index work_count
// (and possibly a cached Folder detail) always changes.
prksMarkFoldersDomainChanged();
}
if (!res.ok) {
const errText = data.error || 'Could not create the file.';
if (statusMsg) {
// Only a refusal (4xx) proves nothing was created; a server
// error may come after the row was committed.
statusMsg.textContent = res.status >= 500
? 'PRKS could not finish creating the file. Check your library before trying again.'
: prksWorkCreateFailureText(errText);
statusMsg.classList.remove('hidden');
}
if (data.code === 'PERSON_NOT_FOUND') {
const peopleErr = document.getElementById('upload-people-error');
if (peopleErr) {
peopleErr.textContent = 'A person on this file has not reached PRKS yet. Try again in a moment.';
peopleErr.classList.remove('hidden');
}
return;
}
if (data.code === 'FOLDER_NOT_FOUND' && folderSearchEl) {
// A stale destination (deleted elsewhere) is a Folder-field
// problem: say so there and keep everything else as typed.
if (typeof prksSetWorkModalFieldError === 'function') {
prksSetWorkModalFieldError(
folderSearchEl,
'This folder no longer exists. Choose another folder.',
'work-folder-error'
);
}
if (typeof prksFocusWorkModalControl === 'function') prksFocusWorkModalControl(folderSearchEl);
return;
}
const lower = String(errText).toLowerCase();
if (sourceKind === 'video' && videoUrlEl && lower.indexOf('url') !== -1) {
if (typeof prksSetWorkModalFieldError === 'function') {
prksSetWorkModalFieldError(videoUrlEl, 'Enter a valid YouTube URL.', 'work-video-url-error');
}
if (typeof prksFocusWorkModalControl === 'function') prksFocusWorkModalControl(videoUrlEl);
}
return;
}
const newId = data.id;
let tagsFailed = 0;
if (newId && tagsToAttach.length) {
for (const t of tagsToAttach) {
try {
const tr = await prksRequest(`/api/works/${encodeURIComponent(newId)}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_id: t.id }),
});
if (!tr.ok) throw new Error('tag attach failed');
} catch (_e) {
tagsFailed += 1;
}
}
}
// Sent before a discard: the Work exists with what was asked for, but
// the dialog on screen now (if any) is someone else's.
if (!createFormStillOpen()) return;
// The Work exists now: close and open it either way, so a retry can
// never create a second one. A partial enrichment is said out loud.
closeModals();
if (newId && typeof prksNavigate === 'function') {
prksNavigate('#/works/' + encodeURIComponent(newId));
}
if (tagsFailed && typeof prksAlertMessage === 'function') {
void prksAlertMessage(
`The file was created, but ${tagsFailed === 1 ? 'one tag' : tagsFailed + ' tags'} could not be attached. Add ${tagsFailed === 1 ? 'it' : 'them'} from the file's details.`,
'Tags not attached'
);
}
} finally {
endBusy();
}
};

Check failure on line 5945 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L5447-L5945

Very Complex Method



Expand Down Expand Up @@ -6064,153 +6063,153 @@
}

if (folderBtn) {
folderBtn.onclick = async () => {
/* No offline guard: a folder is created durably, under an id this
* device mints, so it exists and is usable the moment it is saved. */
const ownerCtx =
typeof prksGetFocusedTabContext === 'function' ? prksGetFocusedTabContext() : null;
if (folderBtn.disabled) return;
const payload = {
title: document.getElementById('folder-title').value,
description: document.getElementById('folder-description').value,
parent_id: (() => {
const raw = (document.getElementById('folder-parent-id')?.value || '').trim();
return raw || null;
})()
};
let newFolderId;
try {
newFolderId = await createFolder(payload.title, payload.description, {
parent_id: payload.parent_id,
});
} catch (e) {
if (!prksOfflineWasGuardRefusal(e)) {
await prksAlertMessage((e && e.message) || 'Could not create folder', 'Could not save');
}
return;
}
const data = { id: newFolderId };
const pending = window.__prksPendingWorkFolderAttach;
if (pending && pending.workId && typeof patchWorkFolder === 'function') {
const attachWid = String(pending.workId);
window.__prksPendingWorkFolderAttach = null;
closeModals();
let attachCoherenceToken = null;
try {
attachCoherenceToken = await patchWorkFolder(attachWid, data.id);
} catch (e) {
await prksAlertMessage(
(e && e.message) || 'Folder created but could not assign this file.',
'Error'
);
}
if (typeof fetchWorkDetails === 'function') {
const _aw = await fetchWorkDetails(attachWid);
if (_aw && typeof prksOfflineCacheEntityIfCurrent === 'function' && attachCoherenceToken != null) {
void prksOfflineCacheEntityIfCurrent('work', attachWid, _aw, attachCoherenceToken);
}
if (typeof prksApplyOwnedWorkEntity === 'function' && prksApplyOwnedWorkEntity(ownerCtx, attachWid, _aw)) {
if (ownerCtx && ownerCtx.ui) ownerCtx.ui.workFolderEditing = false;
if (typeof prksTabContextIsFocused === 'function' ? prksTabContextIsFocused(ownerCtx) : false) {
updatePanelContent('details');
}
}
}
return;
}
closeModals();
const ownerRoute = ownerCtx && (ownerCtx.lastResolvedRoute || ownerCtx.route);
if (
ownerRoute &&
ownerRoute.name === 'folders' &&
typeof prksNavigate === 'function'
) {
prksNavigate('#/folders', { replace: true, tabId: ownerCtx.tabId });
return;
}
/* Not a reload: the record is local, and reloading would throw away
* every other pending change on the page. The Folder Library is
* where a new folder belongs, so go there. */
if (typeof prksNavigate === 'function') prksNavigate('#/folders');
};

Check notice on line 6134 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L6066-L6134

Complex Method
}

const playlistBtn = document.getElementById('save-playlist-btn');
if (playlistBtn) {
playlistBtn.onclick = async () => {
/* No connectivity guard: a Playlist is created under an id this
* device mints, and the video waiting to be attached is ordered
* behind that creation by the dependency mechanism. */
const titleEl = document.getElementById('playlist-title');
const descEl = document.getElementById('playlist-description');
const errEl = document.getElementById('playlist-error');
const title = titleEl ? String(titleEl.value || '').trim() : '';
const description = descEl ? String(descEl.value || '').trim() : '';
if (!title) {
if (errEl) {
errEl.textContent = 'Playlist title is required.';
errEl.classList.remove('hidden');
}
return;
}
if (errEl) {
errEl.textContent = '';
errEl.classList.add('hidden');
}
playlistBtn.disabled = true;
const old = playlistBtn.textContent;
playlistBtn.textContent = 'Creating…';
try {
// The durable boundary: it mints the id and enqueues the
// creation. The reconciler owns the cache once the server
// answers.
const newId = await createPlaylist(title, description);
if (!newId) throw new Error('Could not create playlist');
closeModals();
// If a video is waiting to be attached, attach it now. The
// membership names the new playlist, so it is ordered behind
// the creation automatically.
const pending = window.__prksPendingPlaylistAttach;
if (pending && pending.workId) {
try {
await addWorkToPlaylist(newId, pending.workId);
} catch (_e) {}
window.__prksPendingPlaylistAttach = null;
}
// Refresh select controls if mounted.
if (typeof window.__prksRefreshPlaylistSelects === 'function') {
await window.__prksRefreshPlaylistSelects(newId);
}
if (typeof window.__prksRefreshAllPlaylistSelects === 'function') {
await window.__prksRefreshAllPlaylistSelects(newId);
}
// Navigate only when playlist creation came from the playlists index (not from New File flow).
const attachedWork = !!(pending && pending.workId);
const createTabId = typeof window.prksTakePlaylistIndexCreateTabId === 'function'
? window.prksTakePlaylistIndexCreateTabId()
: '';
if (window.__prksReturnToWorkModalAfterPlaylist === true) {
// closeModals() will restore the New File modal.
} else if (!attachedWork && createTabId && typeof prksNavigate === 'function') {
prksNavigate('#/playlists/' + encodeURIComponent(newId), { tabId: createTabId });
}
} catch (e) {
console.error(e);
/* The durable layer names the actual problem -- an unknown base
* for the video that was waiting to be attached, say -- and a
* flat "Could not create playlist." would hide it. */
const message = String((e && e.message) || 'Could not create playlist.');
if (errEl) {
errEl.textContent = message;
errEl.classList.remove('hidden');
} else {
await prksAlertMessage(message, 'Error');
}
} finally {
playlistBtn.disabled = false;
playlistBtn.textContent = old;
}
};

Check notice on line 6212 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L6139-L6212

Complex Method
}

const personFname = document.getElementById('person-fname');
Expand Down Expand Up @@ -6290,150 +6289,150 @@

const saveGroupBtn = document.getElementById('save-group-btn');
if (saveGroupBtn) {
saveGroupBtn.onclick = async () => {
/* No offline guard: a Group is created durably, under an id this
* device mints, so it exists and is usable the moment it is saved. */
const name = document.getElementById('group-name')?.value || '';
const parentHid = document.getElementById('group-parent-id')?.value?.trim() || '';
const parentSearch = document.getElementById('group-parent-search')?.value?.trim() || '';
const description = document.getElementById('group-description')?.value || '';
if (!name.trim()) {
await prksAlertMessage('Group name is required.', 'Validation');
return;
}
if (typeof prksSetButtonBusy === 'function') prksSetButtonBusy(saveGroupBtn, true, { busyLabel: 'Creating…' });
try {
const parentId = await prksResolvePersonGroupParent(parentHid, parentSearch, null);
if (parentId === undefined) return;
const created = await prksCreatePersonGroupDurably({
name: name.trim(), description: description.trim(), parent_id: parentId,
});
closeModals();
/* No reload and no refetch: the record is local, and a reload
* would throw away every other pending change on the page.
* A Groups-index origin navigates that pane. A stale origin
* does not navigate a replaced owner. Ribbon and command
* palette opens do not record one, so they stay unscoped. */
const groupNav = typeof window.prksTakePersonGroupCreateNavigation === 'function'
? window.prksTakePersonGroupCreateNavigation()
: { mode: 'unscoped' };
if (groupNav && groupNav.mode === 'stale') return;
if (created && created.entity_id && typeof prksNavigate === 'function') {
const hash = '#/people/groups/' + encodeURIComponent(created.entity_id);
if (groupNav && groupNav.mode === 'owner' && groupNav.tabId) {
prksNavigate(hash, { tabId: groupNav.tabId });
} else {
prksNavigate(hash);
}
}
} catch (e) {
await prksAlertMessage(
typeof prksPersonGroupSaveMessage === 'function'
? prksPersonGroupSaveMessage(e, 'create this group')
: 'Could not create group.', 'Could not save');
} finally {
if (typeof prksSetButtonBusy === 'function') prksSetButtonBusy(saveGroupBtn, false);
}
};

Check notice on line 6336 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L6292-L6336

Complex Method
}

const saveRoleBtn = document.getElementById('save-role-btn');
saveRoleBtn.onclick = async () => {
/* No offline guard: linking an existing Person to an existing Work is
* durable-first, so it works with or without the server. */
const ownerCtx = typeof prksGetFocusedTabContext === 'function' ? prksGetFocusedTabContext() : null;
const person_id = document.getElementById('role-person-id').value;
const work_id = document.getElementById('role-work-id').value;
if (!person_id || !work_id) {
await prksAlertMessage('Please select both a person and a file.', 'Validation');
return;
}
const role_type = document.getElementById('role-type').value;
const credit_name =
typeof prksResolveRoleCreditNameForLink === 'function'
? prksResolveRoleCreditNameForLink(
'role-link',
person_id,
'role-person-search'
)
: typeof prksReadRoleCreditName === 'function'
? prksReadRoleCreditName('role-link')
: '';
const _cwDupCheck = typeof prksFocusedEntity === 'function' ? prksFocusedEntity('work') : null;
if (
typeof prksWorkHasRoleLink === 'function' &&
_cwDupCheck &&
String(_cwDupCheck.id) === String(work_id) &&
prksWorkHasRoleLink(_cwDupCheck.roles, person_id, role_type)
) {
if (typeof prksShowDuplicateRoleLinkAlert === 'function') {
await prksShowDuplicateRoleLinkAlert(role_type);
}
return;
}
if (typeof prksSetButtonBusy === 'function') prksSetButtonBusy(saveRoleBtn, true, { busyLabel: 'Linking…' });
try {
/* The SAME durable path the Work panel's Link button takes. This
* modal can target a Work other than the one on screen, which is
* why the save reads the base for whichever Work it is given --
* not because the action is different. One semantic decision must
* not be durable on one surface and a direct POST on another. */
const cachedPerson = typeof prksFindPersonInCache === 'function'
? prksFindPersonInCache(person_id) : null;
const personContext = cachedPerson ? {
id: person_id,
first_name: cachedPerson.first_name || '',
last_name: cachedPerson.last_name || '',
aliases: cachedPerson.aliases || '',
canonical_name: typeof prksPersonCanonicalName === 'function'
? prksPersonCanonicalName(cachedPerson) : '',
} : null;
const workSummary = typeof prksWorkCardSummaryForRoleIntent === 'function'
? prksWorkCardSummaryForRoleIntent(_cwDupCheck, work_id) : null;
const result = await prksSaveWorkPersonRoleDurably(
work_id, person_id, role_type, String(credit_name || '').trim(),
personContext, workSummary);
if (result.code !== 'saved') {
const message = result.code === 'unavailable'
? 'This file\u2019s linked people cannot be changed right now. Open it once '
+ 'while connected to PRKS so its link state is prepared.'
: result.code === 'too-long'
? 'That name is too long for this file.'
: result.code === 'busy'
? 'This link is syncing or needs a decision. Try again shortly.'
: result.code === 'dependency-failed'
? 'This person could not be created on PRKS, so they cannot be linked to a file. Discard that creation in Sync Diagnostics and add them again.'
: 'Could not create link.';
if (typeof prksAlertDialog === 'function') {
await prksAlertDialog({ title: 'Could not link', message });
}
return;
}
} catch (e) {
console.error(e);
if (typeof prksAlertDialog === 'function') {
await prksAlertDialog({
title: 'Could not link',
message: 'Could not create link.',
});
}
return;
} finally {
if (typeof prksSetButtonBusy === 'function') prksSetButtonBusy(saveRoleBtn, false);
}
closeModals();
/* Durable save already wrote the pending overlay (and ACK will patch
* the live Work entity). A GET here races that ACK: a pre-link body
* can replace the panel and drop the Unlink control until a later
* remount — the consecutive-edit flake. Refresh from the owned entity
* instead; never reload the whole app for a non-focused target. */
const expectedWork = ownerCtx && ownerCtx.getEntity ? ownerCtx.getEntity('work') : null;
if (expectedWork && String(expectedWork.id) === String(work_id)) {
if (typeof prksSetWorkDetailsMode === 'function') {
void prksSetWorkDetailsMode('people');
} else if (typeof updatePanelContent === 'function') {
updatePanelContent('details');
}
}
};

Check notice on line 6437 in frontend/js/app.js

View check run for this annotation

codefactor.io / CodeFactor

frontend/js/app.js#L6340-L6437

Complex Method
}
60 changes: 60 additions & 0 deletions tests/test_agent_guidance_current.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,66 @@ def test_relationship_removal_never_prunes_tag_identity(self):
normalized_contract,
)

def test_source_comments_point_to_offline_leaf_rules_not_router(self):
api = (ROOT / "frontend" / "js" / "api.js").read_text(encoding="utf-8")
app = (ROOT / "frontend" / "js" / "app.js").read_text(encoding="utf-8")
db_manager = (ROOT / "backend" / "db_manager.py").read_text(encoding="utf-8")

for path, text in (
("frontend/js/api.js", api),
("frontend/js/app.js", app),
("backend/db_manager.py", db_manager),
):
with self.subTest(path=path):
self.assertNotIn(
"docs/agent-rules/offline-pwa.md",
text,
"domain-specific source comments should point to the owning "
"offline leaf, not the rule router",
)

expected_comment_mappings = (
(
"api Positions coherence",
api,
"Independent of the Concepts domain by construction -- see\n"
" * docs/agent-rules/offline-entity-coherence.md.",
),
(
"api browse projection coherence",
api,
"deleteList('works-browse:index') would have to be rewritten everywhere.\n"
" * See docs/agent-rules/offline-browse-protocol.md.",
),
(
"api Folder coherence",
api,
"A cached Folder detail also embeds whole Work cards, so Work display changes\n"
" * stale it too -- see docs/agent-rules/offline-folder-tag-coherence.md.",
),
(
"app Folder validator",
app,
"this only stops a malformed payload from being cached or rendered. See\n"
" * docs/agent-rules/offline-folder-tag-coherence.md.",
),
(
"app Playlist validator",
app,
"title, author_text and published_date. See "
"docs/agent-rules/offline-entity-coherence.md.",
),
(
"backend browse projection",
db_manager,
"its first 100 characters. See "
"docs/agent-rules/offline-browse-protocol.md.",
),
)
for label, text, expected in expected_comment_mappings:
with self.subTest(comment=label):
self.assertIn(expected, text)

def test_router_covers_remaining_durable_family_routes(self):
sync_map = (ROOT / "docs" / "agent-context" / "sync-map.md").read_text(encoding="utf-8")
router = self.offline_pwa
Expand Down
Loading