mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 18:24:32 +00:00
AGENT_GUIDE.md was ~33k tokens, loaded in full by every agent that followed AGENTS.md's 'read it first', whatever the task. A third of it was lookups an agent can get from the code (file map, route table, env var table, settings key list, static asset list) and had already gone stale in several places. - AGENTS.md (~1.8k tokens, always loaded): what the project is, a 'before you touch X, read Y' table, the rules that always apply, workflow (including that a push to master publishes the Docker image), and the traps that have each broken real data or a real phone. It keeps the tool-managed graft block as is. - docs/agents/*.md (11 files, read on demand): the hard-won 'why' and 'do not regress' contracts by topic, sliced from the old guide with nothing hand-rewritten. The derivable lookups are dropped; the few non-derivable facts from them (git-ignored dev compose, built bundles, reference.docx) moved to testing-and-dev.md. - pointer comments at the top of the contract-heavy source files, so the rule surfaces at the point of editing - tests/agentDocs.test.js keeps the docs honest: AGENTS.md has a size budget, every topic file is linked and says when to read it, and any file, function or npm script a doc names must still exist - README and two code comments no longer point at AGENT_GUIDE.md
270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
'use strict';
|
|
// Tree/sort/pin semantics and why: docs/agents/notebooks-and-sharing.md
|
|
|
|
// Pure helpers for Joplin's nested notebooks (a notebook is nested when its
|
|
// `parent_id` points at another notebook).
|
|
//
|
|
// Everything here works on the flat folder list the item service returns
|
|
// (`[{ id, parentId, title, ... }]`). No I/O, no DOM, no ordering of its own:
|
|
// siblings keep the order of the input list, so the SQL ordering (case-folded
|
|
// title, then creation time) and the virtual "All Notes" / "Trash" rows that
|
|
// the nav wraps around the real folders both survive unchanged.
|
|
//
|
|
// The data comes from sync clients we do not control, so the helpers are
|
|
// defensive in the same way Joplin's own `Folder.buildTree` is:
|
|
// - a folder whose parent is not in the list (not visible to this user, or
|
|
// deleted) is shown at the top level instead of disappearing;
|
|
// - a parent cycle (A under B under A) never loops; the first folder of the
|
|
// cycle in list order becomes a top-level folder.
|
|
|
|
const MAX_DEPTH = 100;
|
|
|
|
// Folder whose parent is usable, else '' (top level).
|
|
const effectiveParentOf = (folder, byId) => {
|
|
const parentId = folder.parentId || '';
|
|
if (!parentId || parentId === folder.id) return '';
|
|
return byId.has(parentId) ? parentId : '';
|
|
};
|
|
|
|
const indexFolders = folders => {
|
|
const list = (folders || []).filter(f => f && f.id);
|
|
const byId = new Map();
|
|
for (const folder of list) byId.set(folder.id, folder);
|
|
const childrenOf = new Map();
|
|
for (const folder of list) {
|
|
const parent = effectiveParentOf(folder, byId);
|
|
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
|
childrenOf.get(parent).push(folder);
|
|
}
|
|
return { list, byId, childrenOf };
|
|
};
|
|
|
|
// Nested tree: [{ folder, id, depth, path, pathIds, treeParentId, hasChildren, children }]
|
|
const buildFolderTree = folders => {
|
|
const { list, childrenOf } = indexFolders(folders);
|
|
const seen = new Set();
|
|
|
|
const build = (folder, depth, treeParentId, pathTitles, pathIds) => {
|
|
seen.add(folder.id);
|
|
const path = pathTitles.concat(folder.title || 'Untitled');
|
|
const ids = pathIds.concat(folder.id);
|
|
const node = {
|
|
folder, id: folder.id, depth, path, pathIds: ids, treeParentId, hasChildren: false, children: [],
|
|
};
|
|
if (depth >= MAX_DEPTH) return node;
|
|
for (const child of childrenOf.get(folder.id) || []) {
|
|
if (seen.has(child.id)) continue;
|
|
node.children.push(build(child, depth + 1, folder.id, path, ids));
|
|
}
|
|
node.hasChildren = node.children.length > 0;
|
|
return node;
|
|
};
|
|
|
|
const roots = [];
|
|
for (const folder of childrenOf.get('') || []) {
|
|
if (!seen.has(folder.id)) roots.push(build(folder, 0, '', [], []));
|
|
}
|
|
// Anything never reached is part of a cycle (or below the depth cap):
|
|
// surface it at the top level rather than hiding the notebook.
|
|
for (const folder of list) {
|
|
if (!seen.has(folder.id)) roots.push(build(folder, 0, '', [], []));
|
|
}
|
|
return roots;
|
|
};
|
|
|
|
// Preorder flattening of the tree. Each entry is the original folder object
|
|
// plus { depth, path, pathIds, treeParentId, hasChildren }.
|
|
const flattenFolderTree = folders => {
|
|
const out = [];
|
|
const visit = node => {
|
|
out.push(Object.assign({}, node.folder, {
|
|
depth: node.depth,
|
|
path: node.path,
|
|
pathIds: node.pathIds,
|
|
treeParentId: node.treeParentId,
|
|
hasChildren: node.hasChildren,
|
|
}));
|
|
for (const child of node.children) visit(child);
|
|
};
|
|
for (const root of buildFolderTree(folders)) visit(root);
|
|
return out;
|
|
};
|
|
|
|
// Ids of every folder below `folderId` (not including it), preorder.
|
|
const descendantIds = (folders, folderId) => {
|
|
const { childrenOf } = indexFolders(folders);
|
|
const out = [];
|
|
const seen = new Set([folderId]);
|
|
const stack = (childrenOf.get(folderId) || []).slice().reverse();
|
|
while (stack.length) {
|
|
const folder = stack.pop();
|
|
if (seen.has(folder.id)) continue;
|
|
seen.add(folder.id);
|
|
out.push(folder.id);
|
|
const kids = childrenOf.get(folder.id) || [];
|
|
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// `folderId` plus every folder below it.
|
|
const subtreeIds = (folders, folderId) => [folderId].concat(descendantIds(folders, folderId));
|
|
|
|
// Ancestor ids, top level first, excluding `folderId` itself.
|
|
const ancestorIds = (folders, folderId) => {
|
|
const { byId } = indexFolders(folders);
|
|
const out = [];
|
|
const seen = new Set([folderId]);
|
|
let current = byId.get(folderId);
|
|
while (current) {
|
|
const parent = effectiveParentOf(current, byId);
|
|
if (!parent || seen.has(parent)) break;
|
|
seen.add(parent);
|
|
out.unshift(parent);
|
|
current = byId.get(parent);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// Total note count per folder including every descendant.
|
|
// directCounts: Map(folderId -> notes directly in that folder)
|
|
const rollupCounts = (folders, directCounts) => {
|
|
const totals = new Map();
|
|
const direct = directCounts || new Map();
|
|
const visit = node => {
|
|
let total = Number(direct.get(node.id) || 0);
|
|
for (const child of node.children) total += visit(child);
|
|
totals.set(node.id, total);
|
|
return total;
|
|
};
|
|
for (const root of buildFolderTree(folders)) visit(root);
|
|
return totals;
|
|
};
|
|
|
|
// Mirror of Joplin's Folder.canNestUnder (structure only). Share/vault rules
|
|
// need services and live in the route layer.
|
|
// targetId '' means "top level".
|
|
const canNestUnder = (folders, folderId, targetId) => {
|
|
const { byId } = indexFolders(folders);
|
|
const target = `${targetId || ''}`;
|
|
if (!folderId || !byId.has(folderId)) return { ok: false, reason: 'Notebook not found' };
|
|
if (!target) return { ok: true, reason: '' };
|
|
if (!byId.has(target)) return { ok: false, reason: 'Target notebook not found' };
|
|
if (target === folderId) return { ok: false, reason: 'Cannot move a notebook into itself' };
|
|
if (descendantIds(folders, folderId).includes(target)) {
|
|
return { ok: false, reason: 'Cannot move a notebook into one of its own sub-notebooks' };
|
|
}
|
|
return { ok: true, reason: '' };
|
|
};
|
|
|
|
// ── Ordering ────────────────────────────────────────────────────────────────
|
|
// Sibling order always comes from the input list, so "how notebooks are sorted" is
|
|
// decided by sorting the FLAT list before it is turned into a tree. Because the tree is
|
|
// built from parent ids, reordering can never move a notebook away from its parent: a
|
|
// notebook is always drawn under its parent, whatever the order.
|
|
|
|
// "Most recent": newest first, by the latest note activity anywhere in the notebook's
|
|
// subtree (so a parent rises with its busiest child, like Joplin's own "updated date"
|
|
// folder sort), falling back to the notebook's creation time when nothing is in it.
|
|
// activity: Map(folderId -> ms of the latest note directly in that notebook)
|
|
// Stable: notebooks with the same recency keep the incoming (alphabetical) order. Each
|
|
// returned folder remembers its incoming position as `alphaRank` so pickers can restore
|
|
// alphabetical order without re-deriving the database's collation (inAlphabeticalOrder).
|
|
const sortFoldersByRecent = (folders, activity) => {
|
|
const list = (folders || []).filter(f => f && f.id);
|
|
const acts = activity || new Map();
|
|
const latest = new Map();
|
|
const visit = node => {
|
|
let value = Math.max(Number(acts.get(node.id) || 0), Number(node.folder.createdTime || 0));
|
|
for (const child of node.children) value = Math.max(value, visit(child));
|
|
latest.set(node.id, value);
|
|
return value;
|
|
};
|
|
for (const root of buildFolderTree(list)) visit(root);
|
|
return list
|
|
.map((folder, index) => ({ folder: Object.assign({}, folder, { alphaRank: index }), index }))
|
|
.sort((a, b) => (latest.get(b.folder.id) - latest.get(a.folder.id)) || (a.index - b.index))
|
|
.map(entry => entry.folder);
|
|
};
|
|
|
|
// Undo sortFoldersByRecent. Lists that were never reordered (no alphaRank) are returned
|
|
// as they are: they are already alphabetical.
|
|
const inAlphabeticalOrder = folders => {
|
|
const list = folders || [];
|
|
if (!list.length || !list.every(f => f && Number.isFinite(f.alphaRank))) return list;
|
|
return list.slice().sort((a, b) => a.alphaRank - b.alphaRank);
|
|
};
|
|
|
|
// Pinned notebooks come first among their siblings, whichever order is active. Like the
|
|
// recency order this runs on the FLAT list, so a notebook can never be separated from its
|
|
// parent. Rank: 2 = pinned itself, 1 = contains a pinned notebook somewhere below (so a
|
|
// pinned NESTED notebook pulls its ancestors up with it and stays reachable at the top,
|
|
// still drawn under its parents), 0 = everything else. Stable within a rank, so the active
|
|
// A-Z / recent order decides among pinned notebooks. Folders get `isPinned`, and keep (or
|
|
// get) `alphaRank` so pickers can undo the reordering (inAlphabeticalOrder).
|
|
const applyPinnedFirst = (folders, pinnedIds) => {
|
|
const list = (folders || []).filter(f => f && f.id);
|
|
const pinned = new Set(pinnedIds || []);
|
|
if (!list.some(f => pinned.has(f.id))) return folders;
|
|
const rank = new Map();
|
|
const visit = node => {
|
|
let value = pinned.has(node.id) ? 2 : 0;
|
|
for (const child of node.children) {
|
|
if (visit(child) > 0 && value < 1) value = 1;
|
|
}
|
|
rank.set(node.id, value);
|
|
return value;
|
|
};
|
|
for (const root of buildFolderTree(list)) visit(root);
|
|
return list
|
|
.map((folder, index) => ({
|
|
folder: Object.assign({}, folder, {
|
|
alphaRank: Number.isFinite(folder.alphaRank) ? folder.alphaRank : index,
|
|
isPinned: pinned.has(folder.id),
|
|
}),
|
|
index,
|
|
}))
|
|
.sort((a, b) => (rank.get(b.folder.id) - rank.get(a.folder.id)) || (a.index - b.index))
|
|
.map(entry => entry.folder);
|
|
};
|
|
|
|
const MAX_PINNED_FOLDERS = 100;
|
|
|
|
const FOLDER_SORTS = ['alpha', 'recent'];
|
|
const normalizeFolderSort = value => (value === 'recent' ? 'recent' : 'alpha');
|
|
|
|
// "Work / Projects / Alpha"
|
|
const folderPathString = (entryOrPath, separator = ' / ') => {
|
|
const path = Array.isArray(entryOrPath) ? entryOrPath : (entryOrPath && entryOrPath.path) || [];
|
|
return path.join(separator);
|
|
};
|
|
|
|
// Label for flat controls (a <select>, the mobile move sheet). Nesting is shown
|
|
// with non-breaking indentation because <option> cannot nest, and plain spaces
|
|
// would be collapsed.
|
|
const INDENT_UNIT = '\u00a0\u00a0';
|
|
const folderOptionLabel = entry => {
|
|
const title = (entry && entry.title) || 'Untitled';
|
|
const depth = Math.min(Math.max(Number(entry && entry.depth) || 0, 0), 8);
|
|
if (!depth) return title;
|
|
return `${INDENT_UNIT.repeat(depth)}\u21b3\u00a0${title}`;
|
|
};
|
|
|
|
module.exports = {
|
|
MAX_DEPTH,
|
|
buildFolderTree,
|
|
flattenFolderTree,
|
|
descendantIds,
|
|
subtreeIds,
|
|
ancestorIds,
|
|
rollupCounts,
|
|
canNestUnder,
|
|
folderPathString,
|
|
folderOptionLabel,
|
|
sortFoldersByRecent,
|
|
applyPinnedFirst,
|
|
MAX_PINNED_FOLDERS,
|
|
inAlphabeticalOrder,
|
|
FOLDER_SORTS,
|
|
normalizeFolderSort,
|
|
};
|