@@ -226,6 +226,135 @@ export async function browseChannelTree(
226226 return toTreeNode ( node ) ;
227227}
228228
229+ // ---- Offline subtree, Studio-shaped, with byte sizes (ADFA-5094) ------------------
230+ // The app's LocalTreeSource reads this and parses it with the SAME mapper it uses for
231+ // Studio, so the wire shape here is Studio's public contentnode_tree — not the granular
232+ // TreeNode above (which the web wizard uses and which carries no bytes).
233+ //
234+ // Why raw SQL and not a Kolibri API: the granular endpoint has no byte sizes, and
235+ // importexportsizeview reports only a selection's OUTSTANDING transfer, never per-node
236+ // structural size. The per-node bytes live in content_localfile.file_size, so they are
237+ // summed here over each node's MPTT (lft/rght) range. That is what lets the picker show
238+ // sizes at every level with no network.
239+ //
240+ // NOTE (verification): the SQL runs against a live Kolibri database, which cannot be
241+ // exercised in CI — it is validated on a box with a metadata-imported channel. If a column
242+ // or the schema differs, this throws, the route returns 500, and LocalTreeSource falls back
243+ // to Studio: a wrong query degrades to today's behaviour, it does not break the picker.
244+
245+ interface StudioFile { file_size : number ; preset : string ; }
246+ interface StudioNode {
247+ id : string ;
248+ title : string ;
249+ kind : string ;
250+ is_leaf : boolean ;
251+ lft : number ;
252+ rght : number ;
253+ files : StudioFile [ ] ;
254+ children : { more : unknown | null ; results : StudioNode [ ] } ;
255+ }
256+
257+ interface CnRow {
258+ id : string ;
259+ parent_id : string | null ;
260+ title : string ;
261+ kind : string ;
262+ lft : number ;
263+ rght : number ;
264+ }
265+ interface FileRow { node_id : string ; preset : string ; file_size : number ; }
266+
267+ // A generous ceiling that keeps the JSON well under LocalTreeSource's 8 MB cap while
268+ // covering ordinary channels whole. A subtree larger than this is truncated and its root
269+ // is marked incomplete, so the mapper reports the size as unknown rather than under-counting.
270+ const SUBTREE_NODE_CAP = 20000 ;
271+
272+ /**
273+ * One channel subtree rooted at {@code nodeId}, in Studio's contentnode_tree shape, built
274+ * from the local content database. Returns {@code null} when the node is not present — i.e.
275+ * the channel's metadata has not been imported — which the app reads as "fall back to Studio".
276+ *
277+ * The channel is scoped by the root node's own channel_id + MPTT range, so a node id alone
278+ * is enough (as with Studio), and no channelId has to be threaded from the caller.
279+ */
280+ export function buildLocalSubtree ( nodeId : string ) : StudioNode | null {
281+ if ( ! fs . existsSync ( MAIN_DB ) ) return null ;
282+ const db = new Database ( MAIN_DB , { readonly : true } ) ;
283+ try {
284+ const root = db . prepare (
285+ `SELECT id, parent_id, title, kind, lft, rght, channel_id
286+ FROM content_contentnode WHERE id = ?` ,
287+ ) . get ( nodeId ) as ( CnRow & { channel_id : string } ) | undefined ;
288+ if ( ! root ) return null ;
289+
290+ const rows = db . prepare (
291+ `SELECT id, parent_id, title, kind, lft, rght
292+ FROM content_contentnode
293+ WHERE channel_id = ? AND lft >= ? AND rght <= ?
294+ ORDER BY lft
295+ LIMIT ?` ,
296+ ) . all ( root . channel_id , root . lft , root . rght , SUBTREE_NODE_CAP ) as CnRow [ ] ;
297+
298+ // Real content files only: thumbnails and supplementary files are excluded so the
299+ // sizes match a normal import, the same exclusion StudioCatalogMapper makes.
300+ // No LIMIT on the subquery (unlike the rows query): it must cover every node the rows
301+ // query kept — an ORDER-BY-less LIMIT here could select a different subset and leave a
302+ // built node without its files, under-counting its size. Files for nodes past the cap
303+ // are simply never looked up.
304+ const files = db . prepare (
305+ `SELECT f.contentnode_id AS node_id, f.preset AS preset, lf.file_size AS file_size
306+ FROM content_file f
307+ JOIN content_localfile lf ON lf.id = f.local_file_id
308+ WHERE f.thumbnail = 0 AND f.supplementary = 0
309+ AND f.contentnode_id IN (
310+ SELECT id FROM content_contentnode
311+ WHERE channel_id = ? AND lft >= ? AND rght <= ?)` ,
312+ ) . all ( root . channel_id , root . lft , root . rght ) as FileRow [ ] ;
313+
314+ const filesByNode = new Map < string , StudioFile [ ] > ( ) ;
315+ for ( const f of files ) {
316+ const arr = filesByNode . get ( f . node_id ) ?? [ ] ;
317+ arr . push ( { file_size : f . file_size ?? 0 , preset : f . preset ?? '' } ) ;
318+ filesByNode . set ( f . node_id , arr ) ;
319+ }
320+
321+ const byId = new Map < string , StudioNode > ( ) ;
322+ for ( const r of rows ) {
323+ byId . set ( r . id , {
324+ id : r . id ,
325+ title : r . title ?? '' ,
326+ kind : r . kind ?? '' ,
327+ is_leaf : r . kind !== 'topic' ,
328+ lft : r . lft ,
329+ rght : r . rght ,
330+ files : filesByNode . get ( r . id ) ?? [ ] ,
331+ children : { more : null , results : [ ] } ,
332+ } ) ;
333+ }
334+ for ( const r of rows ) {
335+ if ( r . id === root . id || ! r . parent_id ) continue ;
336+ byId . get ( r . parent_id ) ?. children . results . push ( byId . get ( r . id ) ! ) ;
337+ }
338+
339+ const rootNode = byId . get ( root . id ) ?? null ;
340+ // Truncation correctness. Rows are ordered by lft and capped, so the dropped nodes are
341+ // exactly those with lft beyond the last one kept. Any node whose range (lft..rght)
342+ // extends past that cutoff therefore has a missing descendant, so mark it incomplete —
343+ // the mapper then reports its subtree size as unknown instead of silently under-counting.
344+ // Over-marking is safe; under-marking is the bug. Nodes fully within the cutoff keep a
345+ // complete child set and an exact size.
346+ if ( rows . length >= SUBTREE_NODE_CAP ) {
347+ const cutoffLft = rows [ rows . length - 1 ] . lft ;
348+ for ( const node of byId . values ( ) ) {
349+ if ( node . rght > cutoffLft ) node . children . more = { cursor : 'truncated' } ;
350+ }
351+ }
352+ return rootNode ;
353+ } finally {
354+ db . close ( ) ;
355+ }
356+ }
357+
229358export interface SelectionSize {
230359 resourceCount : number ;
231360 fileSize : number ;
0 commit comments