#!/usr/bin/env python3
"""Rebuild CMS field manifests without reserializing original HTML/SVG markup.

Only required when changing the developer-owned source HTML. Runtime is PHP.
Requires Python 3, lxml and Pillow for this development command only.
"""
from pathlib import Path
from html.parser import HTMLParser
from html import unescape
from collections import defaultdict
import json, re, shutil, hashlib
from lxml import html as lhtml
from PIL import Image

ROOT = Path(__file__).resolve().parents[1]
VOID = set('area base br col embed hr img input link meta param source track wbr'.split())
PROTECTED = set('dashboard my-property message my-favorites reviews my-profile add-property'.split())
TITLES = {'index':'Homepage 01','index2':'Homepage 02','index3':'Homepage 03','index4':'Homepage 04',
          'about-us':'About us','our-service':'Our services','faq':'Frequently asked questions',
          'blog':'Journal · sidebar','blog-grid':'Journal · grid','blog-detail':'Article layout',
          'contact':'Contact us','privacy-policy':'Privacy policy','pricing':'Pricing',
          'property-halfmap-grid':'Properties · half map grid','property-halfmap-list':'Properties · half map list',
          'sidebar-grid':'Properties · sidebar grid','sidebar-list':'Properties · sidebar list',
          'topmap-grid':'Properties · top map grid','topmap-list':'Properties · top map list'}

class Node:
    def __init__(self,tag,attrs,start,open_end,parent):
        self.tag,self.attrs,self.start,self.open_end,self.parent=tag,dict(attrs),start,open_end,parent
        self.end_start=self.end=open_end
        self.children=[]
        if parent: parent.children.append(self)
    def has(self,c): return c in self.attrs.get('class','').split()
    def ancestors(self):
        n=self
        while n:
            yield n
            n=n.parent
    def inside(self,n): return self.start>=n.start and self.end<=n.end

class Tokens(HTMLParser):
    def __init__(self,raw):
        super().__init__(convert_charrefs=True)
        self.raw=raw;self.lines=[0]+[m.end() for m in re.finditer('\n',raw)]
        self.nodes=[];self.stack=[];self.texts=[]
        self.feed(raw)
    def pos(self):
        a,b=self.getpos();return self.lines[a-1]+b
    def handle_starttag(self,tag,attrs):
        a=self.pos();b=a+len(self.get_starttag_text())
        n=Node(tag,attrs,a,b,self.stack[-1] if self.stack else None)
        self.nodes.append(n)
        if tag not in VOID:self.stack.append(n)
    def handle_startendtag(self,tag,attrs):
        self.handle_starttag(tag,attrs)
        if self.stack and self.stack[-1].tag==tag:self.stack.pop()
    def handle_endtag(self,tag):
        a=self.pos();b=self.raw.find('>',a)+1
        for i in range(len(self.stack)-1,-1,-1):
            if self.stack[i].tag==tag:
                n=self.stack[i];n.end_start=a;n.end=b
                del self.stack[i:];break
    def handle_data(self,data):
        if not data.strip() or not self.stack:return
        a=self.pos();b=self.raw.find('<',a)
        if b<0:b=len(self.raw)
        self.texts.append((a,b,self.stack[-1],unescape(self.raw[a:b])))

def plain(raw): return re.sub(r'\s+',' ',unescape(re.sub('<[^>]+>',' ',raw))).strip()
def slugify(s): return re.sub('[^a-z0-9]+','-',s.lower()).strip('-')
def cl(doc,c):return doc.xpath('.//*[contains(concat(" ",normalize-space(@class)," ")," '+c+' ")]')
def tx(els,default=''):return ' '.join(els[0].text_content().split()) if els else default
def image(els,default=''):
    if not els:return default
    return '/'+els[0].get('src','').lstrip('/')
def number(s,default=0):
    m=re.search(r'[\d,]+(?:\.\d+)?',s)
    return float(m[0].replace(',','')) if m else default

def collections_seed():
    home=lhtml.parse(str(ROOT/'template/index.html'))
    props=[];seen=set()
    property_sources=[]
    for filename in ['index.html','index2.html','index4.html']:
        doc=lhtml.parse(str(ROOT/'template'/filename))
        property_sources+=cl(doc,'property-single-boxarea')+cl(doc,'property-listing-boxarea')
    for e in property_sources:
        title=tx(cl(e,'title'))
        if not title or title in seen:continue
        seen.add(title)
        facts=[tx([x]) for x in e.xpath('.//*[contains(@class,"property-other-widget")]/ul/li')]
        imgs=e.xpath('.//*[contains(@class,"property-list-img-area")]//img') or e.xpath('.//img[contains(@src,"properties/")]')
        # Price markup differs by layout: homepage 1 uses .price, homepage 2
        # nests an H3 inside .nm-btn, and homepage 4 puts the price directly
        # in an A.nm-btn. Read the priced element itself in every variant.
        price_text=next((tx([n]) for n in cl(e,'price')+cl(e,'nm-btn') if re.search(r'\$\s*[\d,]+',tx([n]))),'')
        if not price_text:raise ValueError('Missing source price for property: '+title)
        props.append(dict(title=title,slug=slugify(title),price=number(price_text),price_suffix='/ month' if 'month' in price_text else '',
          address=tx(e.xpath('.//*[contains(@class,"property-price")]//p')) or tx(e.xpath('.//*[contains(@class,"content-area")]/p')),type='Villa' if 'Villa' in title else 'House',
          bedrooms=number(facts[1]) if len(facts)>1 else 3,bathrooms=number(facts[2]) if len(facts)>2 else 2,
          area=number(facts[0]) if facts else 1500,image=image(imgs),gallery=['/'+x.get('src') for x in imgs],
          description='Discover '+title+', a thoughtfully designed home with inviting living spaces and room to make it your own. Contact our team for availability and to arrange a private viewing.',
          featured=len(props)<3,listing_type='For Rent' if 'month' in price_text else 'For Sale',agent=tx(e.xpath('.//*[contains(@class,"name-area")]//a')),status='published'))
    posts=[]
    for e in cl(home,'vl-blog-1-item'):
        title=tx(cl(e,'vl-blog-1-title'))
        posts.append(dict(title=title,slug=slugify(title),excerpt='Practical advice and insights to help you take the next step in your property journey.',
           body='Every property journey begins with a clear plan. Take time to understand your needs, agree on your budget, and explore the areas that fit your lifestyle.\n\nCompare your options carefully and ask questions about the home, its surroundings, and the purchasing process. Our team can help you arrange viewings and understand the next steps.\n\nContact us for a personalized conversation about your property goals.',
           category='Property insights',author='Housa editorial team',published_date='2026-10-01',
           image=image(e.xpath('.//*[contains(@class,"vl-blog-1-thumb")]//img')),status='published'))
    agents=[]
    for e in cl(home,'team-widget-boxarea'):
        agents.append(dict(name=tx(e.xpath('.//*[contains(@class,"textarea")]/a')),role=tx(e.xpath('.//*[contains(@class,"textarea")]/p')),
          email='',phone='',image=image(e.xpath('.//img')),bio='',status='published'))
    doc=lhtml.parse(str(ROOT/'template/our-service.html'))
    services=[]
    for e in cl(doc,'service-single-boxarea'):
        links=e.xpath('.//a[normalize-space(string())]')
        title=tx(links) or tx(e.xpath('.//h2|.//h3|.//h4'))
        services.append(dict(title=title,description=tx(e.xpath('.//p')),image=image(e.xpath('.//img')),link='/contact.html',status='published'))
    faq=lhtml.parse(str(ROOT/'template/faq.html'));faqs=[];seen=set()
    for e in cl(faq,'accordion-item'):
        q=tx(cl(e,'accordion-button'))
        if q in seen:continue
        seen.add(q)
        faqs.append(dict(question=q,answer=tx(cl(e,'accordion-body')),status='published'))
    testimonials=[];seen=set()
    for e in cl(home,'testimonials-box-2'):
        quote=tx(e.xpath('.//p'))
        if not quote or quote in seen:continue
        seen.add(quote)
        name=tx(e.xpath('.//h4|.//h5|.//a[normalize-space(string())]'),'Housa client')
        testimonials.append(dict(name=name,role='Client',quote=quote,rating=5,image=image(e.xpath('.//img')),status='published'))
    return dict(properties=props,posts=posts,agents=agents,services=services,testimonials=testimonials,faqs=faqs)

def compile_page(path):
    raw=path.read_text(encoding='utf-8-sig');p=Tokens(raw);slug=path.stem
    title=TITLES.get(slug,slug.replace('-',' ').title())
    category='Admin templates' if slug in PROTECTED else ('Homepages' if slug.startswith('index') else ('Property layouts' if slug.startswith('property-details') else ('Property listings' if any(x in slug for x in ['map','sidebar-']) else ('Articles' if slug.startswith('blog') else 'Site pages'))))
    body=next(n for n in p.nodes if n.tag=='body')
    tab_labels={n.attrs.get('data-bs-target',''):plain(raw[n.open_end:n.end_start]) for n in p.nodes if n.attrs.get('data-bs-toggle') in ['pill','tab']}
    property_tab_filters={'For Sale':{'listing_type':'For Sale'},'Rent':{'listing_type':'For Rent'},
                         'Villas':{'type':'Villa'},'Apartments':{'type':'Apartment'},
                         'Houses':{'type':'House'},'Retail':{'type':'Retail'}}
    blocks=[];patches=[];sections=[];fields=[];bindings=[]
    def block(a,b,s):
        if any(a<x[1] and b>x[0] for x in blocks):return False
        blocks.append((a,b,s));return True
    def blocked(a,b):return any(a<x[1] and b>x[0] for x in blocks)
    def covered(a,b):return any(a>=x[0] and b<=x[1] for x in blocks)
    def change_attr(n,key,value):
        tag=raw[n.start:n.open_end]
        pat=r'(?i)(?<![\w:-])'+re.escape(key)+r'\s*=\s*([\"\x27])(.*?)\1'
        m=re.search(pat,tag,re.S)
        if m:
            a=n.start+m.start(2);b=n.start+m.end(2)
            patches.append((a,b,value));return True
        return False
    def set_attr(n,key,value):
        if not change_attr(n,key,value):
            patches.append((n.open_end-1,n.open_end-1,' '+key+'="'+value+'"'))
    for n in p.nodes:
        if n.tag=='nav' and n.has('vl-mobile-menu-active'):
            block(n.open_end,n.end_start,'{{system:navigation}}')
        if n.has('preloader') or n.has('header-search-form-wrapper') or n.has('body-overlay'):
            block(n.start,n.end,'')
        if n.tag=='script' and 'counter.js' in n.attrs.get('src',''):
            block(n.start,n.end,'')
    # Replace each repeated group once, keeping its original columns/carousel wrapper.
    definitions={'properties':['property-single-boxarea','property-listing-boxarea'],
      'posts':['vl-blog-1-item'],'agents':['team-widget-boxarea','team2-boxarea','team4-box'],
      'services':['service-single-boxarea'],'testimonials':['testimonials-box-2','testimonial-single-boxarea','testimonial-slider-content-area','testimonial-card'],
      'faqs':['accordion-item']}
    if slug not in PROTECTED:
        for typ,classes in definitions.items():
            if typ=='faqs' and slug.startswith('property-details'):continue
            groups=defaultdict(list)
            partitions=defaultdict(list)
            for n in p.nodes:
                if not any(n.has(c) for c in classes) or blocked(n.start,n.end):continue
                wrap=n.parent if n.parent and (any(c.startswith('col-') for c in n.parent.attrs.get('class','').split()) or n.parent.has('swiper-slide')) else n
                if wrap.parent:groups[wrap.parent].append(wrap)
            for container,items in groups.items():
                items=sorted(set(items),key=lambda n:n.start)
                if any(x not in items and raw[x.start:x.end].strip() and not re.fullmatch(r'space\d+',x.attrs.get('class','')) for x in container.children):
                    # Only replace the contiguous card interval, preserving headings and pagination.
                    between=[x for x in container.children if x.start>=items[0].start and x.end<=items[-1].end]
                    if any(x not in items and not re.fullmatch(r'space\d+',x.attrs.get('class','')) for x in between):continue
                ident=f'b{len(bindings)+1:03d}'
                if block(items[0].start,items[-1].end,'{{collection:'+ident+'}}'):
                    binding=dict(id=ident,type=typ,wrapper_class=items[0].attrs.get('class','') if items[0].parent==container and not any(items[0].has(c) for c in classes) else '',
                        container_class=container.attrs.get('class',''),limit=0 if typ=='faqs' or category in ['Property listings','Articles'] else min(len(items),6),original_count=len(items))
                    if typ=='properties':
                        pane=next((a for a in container.ancestors() if a.has('tab-pane')),None)
                        tab_label=tab_labels.get('#'+pane.attrs.get('id',''),'') if pane else ''
                        if tab_label in property_tab_filters:binding['filters']=dict(property_tab_filters[tab_label])
                    bindings.append(binding)
                    if typ=='faqs':
                        # A visible FAQ tab contains two accordion columns. They
                        # share one collection and must show disjoint portions.
                        partition=next((a for a in container.ancestors() if a.has('tab-pane')),None)
                        if partition is None:
                            partition=next((a for a in container.ancestors() if a.has('faq-section-area')),body)
                        partitions[partition].append(binding)
            for partition_bindings in partitions.values():
                for i,binding in enumerate(partition_bindings):
                    binding['partition_index']=i
                    binding['partition_count']=len(partition_bindings)
    skip={'preloader','paginacontainer','body-overlay','homepage1-body','homepage2-body','homepage3-body','homepage4-body','header-search-form-wrapper'}
    for n in body.children:
        # Nested collection replacements do not remove their enclosing section.
        # Only omit a body section when a structural block removes all of it.
        if n.tag not in ['div','section','main'] or n.attrs.get('class','') in skip or covered(n.start,n.end):continue
        classes=n.attrs.get('class','')
        if re.fullmatch(r'space\d+.*',classes) or not classes:continue
        sid=f's{len(sections)+1:02d}'
        headings=[x for x in p.nodes if x.inside(n) and x.tag in ['h1','h2','h3'] and not blocked(x.start,x.end)]
        name=plain(raw[headings[0].open_end:headings[0].end_start])[:65] if headings else classes.split()[0].replace('-',' ').title()
        if 'footer' in classes:name='Footer & call to action'
        end=n.end
        idx=body.children.index(n)+1
        while idx<len(body.children) and re.fullmatch(r'space\d+.*',body.children[idx].attrs.get('class','')):
            end=body.children[idx].end;idx+=1
        sections.append(dict(id=sid,label=name))
        n.cms_section=sid
        patches.append((n.start,n.start,'<!--cms-section:'+sid+'-->'))
        patches.append((end,end,'<!--/cms-section:'+sid+'-->'))
    def sec(n):
        for a in n.ancestors():
            if hasattr(a,'cms_section'):return a.cms_section
        return 'global'
    def field(n,typ,label,value):
        fid=f'f{len(fields)+1:04d}'
        fields.append(dict(id=fid,type=typ,label=label[:95],section=sec(n),default=value))
        return '{{cms:'+fid+'}}'
    for a,b,n,text in p.texts:
        if blocked(a,b) or any(x.tag in ['head','script','style','svg','noscript'] for x in n.ancestors()):continue
        val=text.strip()
        if not val or val in ['+','|','/','•']:continue
        brand=None
        if n.parent and n.parent.has('footer-time-area') and n.tag=='p':brand='footer_description'
        if any(x.has('copyright-area') for x in n.ancestors()) and '©' in val:brand='copyright'
        if any(x.tag=='a' and x.attrs.get('href','').startswith('tel:') for x in n.ancestors()):brand='phone'
        if any(x.tag=='a' and x.attrs.get('href','').startswith('mailto:') for x in n.ancestors()):brand='email'
        if val=='421 Allen, Mexico 4233':brand='address'
        token='{{brand:'+brand+'}}' if brand else field(n,'textarea' if len(val)>100 else 'text',f'{n.tag.upper()} · '+val,val)
        prefix=text[:len(text)-len(text.lstrip())];suffix=text[len(text.rstrip()):]
        patches.append((a,b,prefix+token+suffix))
    for n in p.nodes:
        if blocked(n.start,n.open_end):continue
        attrs=n.attrs
        if n.tag=='html':
            change_attr(n,'lang','{{brand:language}}')
            patches.append((n.open_end-1,n.open_end-1,' dir="{{brand:direction}}"'))
        if n.tag=='title':block(n.open_end,n.end_start,'{{system:title}}')
        if n.tag=='img':
            src=attrs.get('src','');brand=src.endswith('/logo1.png') or src.endswith('/logo2.png')
            if brand:
                footer_logo=any('footer' in a.attrs.get('class','').lower() for a in n.ancestors())
                change_attr(n,'src','{{brand:logo_footer}}' if footer_logo else '{{brand:logo}}');change_attr(n,'alt','{{brand:site_name}}')
                patches.append((n.open_end-1,n.open_end-1,' data-brand-logo="true"'))
            else:
                change_attr(n,'src',field(n,'image','Image · '+Path(src).name,'/'+src.lstrip('/')))
                if 'alt' in attrs:change_attr(n,'alt',field(n,'text','Alt text · '+Path(src).name,attrs['alt']))
        if n.tag=='a' and 'href' in attrs:
            url=attrs['href']
            if url.startswith('tel:'):token='tel:{{brand:phone}}'
            elif url.startswith('mailto:'):token='mailto:{{brand:email}}'
            elif url.startswith('#') and url!='#':continue
            elif url.startswith('javascript:'):token='#'
            elif any(x in raw[n.open_end:n.end_start] for x in ['fa-facebook','fa-linkedin','fa-instagram','fa-youtube']):
                social=next(k for k in ['facebook','linkedin','instagram','youtube'] if 'fa-'+k in raw[n.open_end:n.end_start])
                token='{{brand:'+social+'}}'
            else:
                if url in ['case-single.html','doctor.html']:url='contact.html'
                if url.endswith('.html'):url='/'+url
                token=field(n,'link','Link · '+(plain(raw[n.open_end:n.end_start])[:65] or Path(url).name or 'Destination'),url)
            change_attr(n,'href',token)
        for key in ['placeholder','aria-label','title','data-count']:
            if key in attrs:
                typ='number' if key=='data-count' else 'text'
                change_attr(n,key,field(n,typ,key.replace('-',' ').title()+' · '+attrs[key],attrs[key]))
        # Submission field identity must survive content edits and translations.
        # Derive it once from the original template, never from a live placeholder.
        if n.tag in ['input','textarea'] and any(a.has('contact-boxarea') or a.has('details-siderbar2') for a in n.ancestors()):
            placeholder=attrs.get('placeholder','').lower()
            form_name=None
            if attrs.get('type')=='email' or 'email' in placeholder:form_name='email'
            elif n.tag=='textarea' or 'message' in placeholder:form_name='message'
            elif 'last name' in placeholder:form_name='last_name'
            elif 'name' in placeholder:form_name='name'
            elif 'phone' in placeholder:form_name='phone'
            elif 'service' in placeholder or 'subject' in placeholder:form_name='subject'
            if form_name:
                set_attr(n,'name',form_name)
                set_attr(n,'data-cms-field',form_name)
        if n.tag=='iframe' and attrs.get('src'):
            change_attr(n,'src',field(n,'link','Map / embed URL',attrs['src']))
        if 'style' in attrs and 'url(' in attrs['style']:
            m=re.search(r'url\([\"\x27]?([^\"\x27)]+)',attrs['style'])
            if m:change_attr(n,'style',attrs['style'].replace(m[1],field(n,'background','Background image','/'+m[1].lstrip('/'))))
        if n.tag=='link' and 'icon' in attrs.get('rel',''):
            change_attr(n,'href','{{brand:favicon}}')
        if n.tag=='link' and attrs.get('href')=='assets/css/main.css':change_attr(n,'href','/theme.css')
        if n.tag=='script' and attrs.get('src','').startswith('assets/'):
            change_attr(n,'src','/'+attrs['src'])
        if n.tag=='link' and attrs.get('href','').startswith('assets/') and attrs.get('href')!='assets/css/main.css' and 'icon' not in attrs.get('rel',''):
            change_attr(n,'href','/'+attrs['href'])
    # Extra visual media attributes are appended after the original field pass so
    # existing field IDs stay stable across upgrades. These attributes are often
    # used by sliders and JS background helpers instead of <img src>.
    for n in p.nodes:
        if blocked(n.start,n.open_end):continue
        for key in ['data-src','data-bg','data-background','poster']:
            value=n.attrs.get(key,'')
            if not value or '{{' in value or not re.search(r'(?i)\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$',value):continue
            typ='background' if key!='poster' else 'image'
            label=('Background image · ' if typ=='background' else 'Poster image · ')+Path(value.split('?',1)[0]).name
            change_attr(n,key,field(n,typ,label,'/'+value.lstrip('/')))
    head=next(n for n in p.nodes if n.tag=='head')
    patches.append((head.end_start,head.end_start,'{{system:head}}\n<link rel="stylesheet" href="/cms-public.css">\n'))
    patches.append((body.end_start,body.end_start,'\n<script src="/cms-public.js" defer></script>\n{{system:preview}}'))
    # Structural changes win. Every byte outside known edits is left untouched.
    changes=blocks+[x for x in patches if not blocked(x[0],x[1])]
    changes.sort(key=lambda x:(x[0],x[1]),reverse=True)
    prev=len(raw)+1
    for a,b,s in changes:
        if b>prev and a!=b:raise ValueError(f'Overlapping changes in {slug}: {a}:{b}')
        raw=raw[:a]+s+raw[b:];prev=a
    # Frontend assets use root-relative paths, including inline CSS.
    raw=raw.replace('"assets/','"/assets/').replace("'assets/","'/assets/")
    (ROOT/'templates'/path.name).write_text(raw)
    return dict(slug=slug,title=title,category=category,template=path.name,protected=slug in PROTECTED,fields=fields,
                sections=[dict(id='global',label='Header & shared links')]+sections,bindings=bindings)

def main():
    assets=ROOT/'template/assets'
    if assets.exists() and not (ROOT/'public/assets').exists():shutil.copytree(assets,ROOT/'public/assets')
    pages={p.stem:compile_page(p) for p in sorted((ROOT/'template').glob('*.html'))}
    media=[]
    for p in sorted((ROOT/'public/assets/img').rglob('*')):
        if not p.is_file() or p.suffix.lower() not in ['.png','.jpg','.jpeg','.webp','.gif','.svg']:continue
        url='/'+p.relative_to(ROOT/'public').as_posix();w=h=0
        try:
            with Image.open(p) as im:w,h=im.size
        except Exception:pass
        media.append(dict(id='tpl_'+hashlib.sha256(url.encode()).hexdigest()[:16],name=p.name,url=url,alt='',size=p.stat().st_size,width=w,height=h,source='template'))
    settings=dict(site_name='Housa',tagline='A place for your next chapter.',logo='/assets/img/logo/logo1.png',logo_light='',favicon='/assets/img/logo/fav-logo1.png',
       primary_color='#ED8438',secondary_color='#1B1B1B',background_color='#FFFFFF',surface_color='#F5F5F1',text_color='#1B1B1B',muted_color='#5C5D62',border_color='#E5E7EB',
       font_family='Inter',base_font_size=16,border_radius=16,logo_width=150,direction='ltr',language='en',homepage='index',email='hello@example.com',phone='+1 123 456 7890',address='421 Allen, Mexico 4233',
       copyright='© 2026 Housa. All rights reserved.',footer_description='We are committed to making your real estate journey seamless. Find the right home and a team that supports you every step of the way.',
       facebook='',instagram='',linkedin='',youtube='',seo_title='Housa · Find your next home',seo_description='Discover homes, explore your options, and connect with a property expert.',og_image='/assets/img/all-images/hero/hero-img1.png',currency='USD',currency_symbol='$',maintenance_mode=False,custom_css='',
       navigation=[dict(label=a,url=b) for a,b in [('Home','/'),('Properties','/sidebar-grid.html'),('About','/about-us.html'),('Services','/our-service.html'),('Journal','/blog-grid.html'),('Contact','/contact.html')]])
    seed=dict(settings=settings,pages=[dict(slug=k,title=v['title'],category=v['category'],status='draft' if v['protected'] else 'published',protected=v['protected'],content=dict(values={},hidden_sections=[],section_order=[s['id'] for s in v['sections'] if s['id']!='global'],seo_title='',seo_description='')) for k,v in pages.items()],collections=collections_seed())
    (ROOT/'data/manifest.json').write_text(json.dumps(dict(pages=pages,media=media),ensure_ascii=False,indent=2))
    (ROOT/'data/seed.json').write_text(json.dumps(seed,ensure_ascii=False,indent=2))
    print(json.dumps(dict(pages=len(pages),fields=sum(len(p['fields']) for p in pages.values()),bindings=sum(len(p['bindings']) for p in pages.values()),media=len(media),collections={k:len(v) for k,v in seed['collections'].items()})))

if __name__=='__main__':main()
