How do I find programmatically (Python) the "when loading" event for a Form in a Base document?

@vib @Ratslinger

Thanks both. You are both talking my language. I will check out vib’s code (and have examined quite a few of Ratslinger’s copious examples so far…).

FWIW, I did go down the route of examining “what is stored within”, before basically running into the sand (so far… and I have a feeling I wouldn’t get further if I tried):

    forms_ss = doc.getDocumentSubStorage('forms', ElementModes.READWRITE)
    ss_el_names = forms_ss.getElementNames()
    for ss_el_name in ss_el_names:
        form_ss = forms_ss.getByName(ss_el_name)
        print_out_element(form_ss, 'form_ss')
        print(f'form_ss.getElementNames() {form_ss.getElementNames()}')
        # output: 'Configurations2', 'content.xml', 'styles.xml', 'manifest.rdf', 'settings.xml' 
        # ... so we've definitely got hold of the Writer document underlying the form here... 
        # here, ElementMode.READWRITE causes uno.IOException:
        se_conf2 = form_ss.openStorageElement('Configurations2', ElementModes.READ)
        print(f'se_conf2 {se_conf2}')
        print(f'se_conf2.getPropertySetInfo() {se_conf2.getPropertySetInfo()}')
        print(f'se_conf2.getElementNames() {se_conf2.getElementNames()}')
        # here, all ElementMode variables cause uno.IOException:
        # se_manif_rdf = form_ss.openStorageElement('manifest.rdf', ???)
        # ... but it's possible to get a stream:
        se_manif_rdf_stream = form_ss.openStreamElement('manifest.rdf', ElementModes.READ)
        print(f'se_manif_rdf_stream {se_manif_rdf_stream}') # XStream
        # here, all ElementMode variables cause uno.IOException:
        # se_settings = form_ss.openStorageElement('settings.xml', ???)
        # ... but it's possible to get a stream:
        se_settings_stream = form_ss.openStreamElement('settings.xml', ElementModes.READ)
        print(f'se_settings_stream {se_settings_stream}') # XStream

re vib’s point about configuring the form as an XComponent (after the form has been opened: as I mentioned, form.getComponent() returns None before you’ve loaded it)… yes, I think this is probably the only practical way to go, to access, for example, the “When record changes” event (which I need to do).

and … in fact I just examined what changes are made to a .odb file (with embedded macros) when you actually configure it (i.e. hook up macros to events).

It seems to be pretty straightforward: when you expand (unzip) the .mdb there is a directory “forms” … then you try and locate the one you’ve configured (size will have changed, apart from anything else), and under the directory for the form there is a file content.xml.

In my case an extra bit of XML had been injected after I connected up 2 events to 2 different macros:

<office:event-listeners>
    <script:event-listener script:language="ooo:script" script:event-name="dom:load" xlink:href="vnd.sun.star.script:my_2base_script.py$on_load_invoice_form?language=Python&amp;location=document" xlink:type="simple"/>
    <script:event-listener script:language="ooo:script" script:event-name="form:cursormove" xlink:href="vnd.sun.star.script:my_2base_script.py$refresh_subtotal?language=Python&amp;location=document" xlink:type="simple"/>
</office:event-listeners>

content.xml can be read in as above as an XStream… and hopefully edited and replaced … this would be the way to go to make event-handling configuration permanent, rather than having to configure each time the .odb was opened. …


What’s of interest to me here in particular is how the Events in question are designated in the XML language:

script:event-name="dom:load" 

means “When this form is loaded…” (could “dom” mean “Document Object Model”?)

and

script:event-name="form:cursormove"

means “After a record change…”
Interesting: the “load” event may indeed be associated with the top-of-the-hierarchy “ODB Document”, whereas “cursormove” is associated with the form, as it would have to be.


These also give me something to search on. Leading to this page, LO implementation notes.

I now have a working example that can capture the OnLoad event of a form.

The LoadListen class is where everything is implemented.

The XEventListener interface is used to listen for event to be notified when a database form is loaded.

Output of Script:

Note the Notify Event: OnLoad event was fired.

Loading Office...
Opening D:\Users\user\Projects\python-ooouno-ex\resources\odb\Example_Sport.odb
Notify Event: OnVisAreaChanged
Notify Event: OnPageCountChange
Notify Event: OnLoad
Form with name "MainForm" Loaded.
Closing the document
Notify Event: OnLayoutFinished
Notify Event: OnViewClosed
Notify Event: OnUnload
Notify Event: OnUnfocus
Closing Office
Office terminated
Office bridge has gone!!
1 Like

Thanks, that looks great.

It’ll be a day or two before I can see how it works. I’m not sure what issues you can see that make this a superior approach to just using XDocumentEventListener.documentEventOccurred() (and then examining the event) … could you maybe explain in one sentence?

I am not clear on what you are asking. More context please.

I am wondering why your new class here is better (“superior”) than using XDocumentEventListener.documentEventOccurred(), as I suggested (and then examining the event).


In what way is it BETTER?

The method that you suggest does not seem to work with python.

def _notifyEvent(event: EventObject) -> None:
    print(f"Notify Event: {event.EventName}")


self._fn_notifyEvent = _notifyEvent

self.elist = XEventListener()
self.elist.notifyEvent = self._fn_notifyEvent

doc_ui.connect()

comp = doc_ui.loadComponent(DatabaseObject.FORM, "starter", False)
# At this point an exception is thrown
# uno.RuntimeException: Couldn't convert <uno.com.sun.star.document.XEventListener object at 0x0000029461058DF0> to a UNO type; caught exception:
# <class 'AttributeError'>: 'com.sun.star.document.XEventListener' object has no attribute 'getTypes'
comp.addEventListener(self.elist)

It is my understanding that event listener best practices for python are outlined in the two type of event listeners show in OooDev, Python LibreOffice Programming 4.1 Listening to a Window

Sorry, I can’t understand at all where this last comment of yours comes from. Or what you’re trying to achieve with doc_ui.loadComponent(...), etc. The original question couldn’t be simpler: HOW TO DETECT THE OPENING OF A FORM, whether this opening action occurs programmatically or by the user clicking the form icon.


You can see from the answer (my actual answer to the question, not some comment!) I gave that I’m not using code anything like the code you have just included. I’m using XDocumentEventListener:

with Lo.Loader(Lo.ConnectSocket()) as loader:
    doc = Base.open_doc('test_db.odb', loader)
    GUI.set_visible(is_visible=True, odoc=doc)
    class DocEventListener(unohelper.Base, XDocumentEventListener):
        def documentEventOccured(self, event):
            if event.EventName == 'OnSubComponentOpened':
                if event.Supplement != None:
                    # analysis shows that event.Supplement here is indeed the form (`XComponent`)
                    print(f'doc event.Supplement {event.Supplement}')
    doc.addDocumentEventListener(DocEventListener())

Nor do I understand your comment about “listener best practices”. I am listening for an “OnSubComponentOpened” event on on the ODatabaseDocument object (top of hierarchy). Why would I want to use a listener on a window?


Sorry, I simply can’t following your reasoning here, at all.


Again, the question (my question, my simple question) is “why do you think your LoadListen class (which I looked at) is fundamentally better than the solution I have suggested using XDocumentEventListener?”

Did you not see the working example code?

Anyways If you have an answer the rest can be ignored.

Found another answer. This is actually a way of configuring events programmatically, including actions in response to the opening of a form in Base, i.e. event “When loading”. When editing “manually” you access this on the “Events” tab when you edit a form, so it certainly seems to belong to the form. And actually, it does.

It seems you have to open the form to get to this. There is a little bit of confusion about the word “form”. I refer to the thing seen by the user as the “user-seen-form”.

The first thing you have to do, assuming you know the name of the user-seen-form (as it appears as an icon in the main dialog under “Forms” on opening Base) is get the “content”. I call this content because the implementation name is “com.sun.star.comp.sdb.Content”.

contents = doc.FormDocuments.getElementNames()
content = doc.FormDocuments.getByName(form_name)  # implementation name .sdb.Content

The next thing is to get the com.sun.star.form.OFormsCollection object. It is important to realise that each “user-seen-form” has one of these OFormsCollections.

content_component = content.getComponent()
o_forms_collection = content_component.getDrawPage().getForms() # implementation name form.OFormsCollection

NB clearly this variable o_forms_collection can be obtained only if you have access to a genuine XComponent. In fact, getComponent() on content above, representing the user-seen-form, returns None until the user-seen-form in question is actually opened and visible by the user.

This OFormsCollection object seems always to have 2 script events. The first of the two seems to contain things of interest:

o_forms_collection.getScriptEvents(0) 

If there is a macro PythonVersion in embedded module myscript.py, attached to event “When record changes”, this prints as:
((com.sun.star.script.ScriptEventDescriptor){ ListenerType = (string)"XRowSetListener", EventMethod = (string)"cursorMoved", AddListenerParam = (string)"", ScriptType = (string)"Script", ScriptCode = (string)"vnd.sun.star.script:myscript.py$PythonVersion?language=Python&location=share" },)

Rather strangely for what is a “collective” object, it implements XEventAttacherManager, and exposes the events of the user-seen-form. So it’s possible to “edit” the events of the user-seen-form by revokeScriptEvent, insertEntry, etc. This would be a way of configuring events automatically, removing the need to configure manually. When I also manually attached another macro (“Standard.Module1.RemoveImages”), to a different event (“When loading”) on the same user-seen-form getScriptEvents(0) then printed out thus:

((com.sun.star.script.ScriptEventDescriptor){ ListenerType = (string)"XLoadListener", EventMethod = (string)"loaded", AddListenerParam = string)"", ScriptType = (string)"Script", ScriptCode = string)"vnd.sun.star.script:Standard.Module1.RemoveImages?language=Basic&location=application" }, (com.sun.star.script.ScriptEventDescriptor){ ListenerType = (string)"XRowSetListener", EventMethod = (string)"cursorMoved", AddListenerParam = (string)"", ScriptType = (string)"Script", ScriptCode = string)"vnd.sun.star.script:myscript.py$PythonVersion?language=Python&location=share" })

The elements in this OFormsCollection are of implementation name com.sun.star.comp.forms.ODatabaseForm, but all of these belong to the “user-seen-form”, and there can be more than one such for each “user-seen-form”.

These ODatabaseForms implement many interfaces, including things like com.sun.star.sdb.XResultSetAccess, com.sun.star.sdbc.XResultSetUpdate, so the other way to achieve automation, for example when the record set is changed, is to use an appropriate listener.


PS it is possible to obtain the .sdb.Content object when you use an XDocumentListener on the main .odb top-of-hierarchy “document”:

class DocEventListener(unohelper.Base, XDocumentEventListener):
    def documentEventOccured(self, event):
        if event.EventName == 'OnSubComponentOpened':
            if event.Supplement != None:
                if is_uno_interface(event.Supplement, XComponent):
                    frame = event.Supplement # object of implementation name ... frame.XFrame
                    contents = doc.FormDocuments.getElementNames()
                    found_corresponding_form = False
                    # now iterate through the names of all the user-seen-forms:
                    for content_name in contents:
                        content = doc.FormDocuments.getByName(content_name) # object of implementation name sdb.Content
                        content_component = content.getComponent()
                        if content_component == None: # "None" indicates an unopened user-seen-form
                            continue    
                        # this line checks whether the "content" and the "frame" indeed belong to one another...
                        if content_component.getCurrentController().getFrame() == frame:
                            found_corresponding_form = True
                            break
                    if found_corresponding_form == False:
                        return 
                    # at this point, `content` is the sdb.Content corresponding to the frame.XFrame

@mrodent
.
Much of the is similar to a link provided in another of my links above. This one: How to programmatically get/set a form control's events
.
It covers both form and control events and have referenced this in a number of my posts.
.
Then again, you can always create your own → How to properly code a broadcaster and listener in LO Basic - #2 by Ratslinger

I was indeed inspired by that first answer, so Thanks. But in fact exposing the events of the Form itself (such as “When loading”), which is what I wanted to do, i.e. rather than exposing the events of the controls contained in the Form, is arguably slightly more elusive. I don’t see a solution to that there…
.
In fact my main inspiration came from here - you again, so thanks again.
.
The most unlikely thing was when I took the trouble of looking at the humble-looking collection object from content_component.getDrawPage().getForms() and found that it implemented XEventAttacherManager. This still strikes me as an inexplicable design choice… almost as though someone was keen not to make things too easy (I am in jest, perhaps).

For completeness I really also have to include this final answer. I find it tedious in the extreme, when unzipping the .odb and rezipping with embedded macros as another file, to have to go to that new file and laboriously configure the events manually. :yawning_face:

It is in fact possible to make your “embedding script” handle this configuration programmatically. When the .odb is unzipped, forms are located under \forms, and given “technical names” (e.g. “Obj111”). Each form has its own folder, in which we find the usual suspects, content.xml being the one to manipulate.

First we have to find the “technical name” of the form within the .odb file. The publicly available name in this example is “test_kernel.invoices”.
This stage uses “import xml.etree.ElementTree as ET”.

# utility function:
def is_match(pattern, string, flags=re.IGNORECASE | re.DOTALL): 
    return re.fullmatch(pattern, string, flags)!=None

zin = zipfile.ZipFile(existing_odb_file_str)
content_tree = None
for item in zin.infolist():
    buffer = zin.read(item.filename)
    if item.filename == 'content.xml':
        content_tree = ET.ElementTree(ET.fromstring(buffer.decode('utf-8')))
        break
root = content_tree.getroot()
href_dict = {}
for el in root.iter():
    if is_match('.*component', el.tag):
        name = None
        for key, value in el.items():
            if is_match('.*name', key):
                name = value
            elif is_match('.*href', key):
                href = value
                href_dict[name] = href
invoice_content_internal_zip_str = href_dict['test_kernel.invoices'] + '/content.xml'

Now we know which item we’re looking for in the zip, we iterate again, having
obtained a dict, triggers, example:

triggers = [
{'event': 'dom:load', 'macro': 'on_load_invoice_form'},
{'event': 'form:cursormove', 'macro': 'refresh_subtotal'}
]

This is the technique used to reconstitute a tweaked .zip file.

INTERNAL_MANIFEST_PATH_STR = 'META-INF/manifest.xml'
for item in zin.infolist():
    in_buffer = zin.read(item.filename)
    if item.filename == INTERNAL_MANIFEST_PATH_STR:
        # the manifest must also be modified
        manifest = []
        for line in zin.open(INTERNAL_MANIFEST_PATH_STR):
            if '</manifest:manifest>' in line.decode('utf-8'):
                for path in ['Scripts/','Scripts/python/', f'Scripts/python/{macro_script_file_str}']:
                    manifest.append(f' <manifest:file-entry manifest:media-type="application/binary" manifest:full-path="{path}"/>')
            manifest.append(line.decode('utf-8'))
        zout.writestr(INTERNAL_MANIFEST_PATH_STR, ''.join(manifest))
    elif item.filename == invoice_content_internal_zip_str:
        # I initially tried XML parser ElementTree ... but it messed up the "namespaces" too much: the resulting form wouldn't actually open in LO Base. 
        # It is pretty simple parsing as we work through the input buffer
        out_buffer = ''
        phase = 'searching'
        current_element_str = ''
        n_element = 0
        first_form_properties_processed = False
        for i in range(len(in_buffer)):
            ch = chr(in_buffer[i])
            out_buffer += ch
            if ch == '<':
                phase = 'examining'
            elif ch == '>':
                n_element += 1 
                if not first_form_properties_processed:
                    if current_element_str == '/form:properties':
                        first_form_properties_processed = True
                        injected_script = '\n<office:event-listeners>\n'                            
                        for trigger in triggers:
                            href = f'vnd.sun.star.script:{macro_script_file_str}${trigger["macro"]}?language=Python&amp;location=document'
                            script_event_str = \
f'<script:event-listener script:language="ooo:script" script:event-name="{trigger["event"]}" xlink:href="{href}" xlink:type="simple"/>\n'
                            injected_script += script_event_str
                        injected_script += '</office:event-listeners>' 
                        out_buffer += injected_script
                phase = 'searching'
                current_element_str = ''
            elif phase == 'examining':
                current_element_str += ch 
        zout.writestr(item, out_buffer)    
    else:
        zout.writestr(item, in_buffer)
zout.write(macro_script_file_str, f"Scripts/python/{macro_script_file_str}")
zout.close()
zin.close()

For greater clarity, this is what we’re doing to forms/Obj111 [or whatever]/content.xml:

...
<office:body>
  <office:text>
    <office:forms form:automatic-focus="false" form:apply-design-mode="false">
      <form:form form:name="MainForm" form:command="test_kernel.invoices" form:apply-filter="true" 
          form:command-type="table" form:control-implementation="ooo:com.sun.star.form.component.Form" office:target-frame="">
        <form:properties>
          <form:property form:property-name="PropertyChangeNotificationEnabled" office:value-type="boolean" office:boolean-value="true"/>
          <form:property form:property-name="TargetURL" office:value-type="string" office:string-value=""/>
        </form:properties>
        
        # next bit must be inserted:
        <office:event-listeners>
          <script:event-listener script:language="ooo:script" script:event-name="dom:load" xlink:href="vnd.sun.star.script:my_2base_script.
            py$on_load_invoice_form?language=Python&amp;location=document" xlink:type="simple"/>
          <script:event-listener script:language="ooo:script" script:event-name="form:cursormove" xlink:href="vnd.sun.star.script:my_2base_script.
            py$refresh_subtotal?language=Python&amp;location=document" xlink:type="simple"/>
        </office:event-listeners>
        
        
        <form:form form:name="AddressesSubForm" form:command="test_kernel.invoiceitems" form:apply-filter="true" form:command-type="table" 
          form:control-implementation="ooo:com.sun.star.form.component.Form" office:target-frame="" form:master-fields="&quot;InvoiceNo&quot;" 
          form:detail-fields="&quot;InvoiceNo&quot;">
          <form:properties>
            <form:property form:property-name="PropertyChangeNotificationEnabled" office:value-type="boolean" office:boolean-value="true"/>
            <form:property form:property-name="TargetURL" office:value-type="string" office:string-value=""/>
          </form:properties><form:grid form:name="SubForm_Grid" form:control-implementation="ooo:com.sun.star.form.component.GridControl" 
            xml:id="control1" form:id="control1"><form:properties><form:property form:property-name="DefaultControl" office:value-type="string" 
            office:string-value="com.sun.star.form.control.GridControl"/>
          </form:properties>            
          ...

@mrodent
.
Glad you found the answer which suits your need(s). For me this seems like way more work than is needed and the question has taken many trips in a circle.
.
What I have done in the past it to open the form in edit mode (programmatically and hidden or reduced to taskbar), make the mod, save and close. Now the form is ready for use.

1 Like

Yes, but that’s what I refer to as “manual configuration”, i.e. manually join up the event to the macro.
.
But if I’m developing my macro code, I find I’m constantly running the “embed” script and producing a new .odb file. And each time I have to laboriously reconfigure. It puts me off developing.
.
When my .odb is getting towards completion, it’ll probably have 10s of events attached to various things. How tedious would that be to configure by hand. Each. Time. Anyway I put it out there so others can like or dislike, as per!
.
NB tweaking the events of controls on a form is very straightforward after this. The code you have to inject to create a trigger is very generic.

@mrodent
.
You may be interested in this. It is written in Basic:

Interesting. And less fragile no doubt than directly editing the xml. A version change could potentially lead to different xml tags and attributes, for example.

Anything which saves me from manual configuration is good.


later
After a few experiments: unfortunately that script seems to pose a problem, in my setup at least: on the loadComponentWithArguments line I get an exception: “__main__.CannotConvertException: conversion not possible!”. On examination of the value of this exception, the culprit turns out to be the com.sun.star.beans.PropertyValue.

If I then use the method loadComponent, the form displays (i.e. not hidden)… but the next line that wants to use a PropertyValue (or tuple thereof) fails:

prop2 = (PropertyValue(Name='EventType', Value='Script'), 
        PropertyValue(Name='Script', Value='vnd.sun.star.script:Standard.Module1.OnLoad?language=Python&location=document'))
form_doc.Events.replaceByName('OnLoad', prop2)

Failure: __main__.IllegalArgumentException
.
I’m not sure why the Python version of these lines might be failing. NB a simple Python macro is indeed present at Standard.Module1.OnLoad.
.
I have a slight suspicion it might be something to do with threads: I have previously surmised that there might be something akin to an “event thread” in LO apps, and trying to do event things in a non-event thread may be the source of various failures. But this is just speculation.

NB What is this you keep using?
.

.
Not true. A python macro would have .py. See → Can I embed python script and integrate with basic? (solved) - #10 by Ratslinger
.
If you have further issues, please post a redacted sample showing the issue. All these comments are just running in circles.
.
Thank You

You’re right, well spotted. I was just simplifying the name … because it didn’t seem that relevant. The macro name is correct in this script… which fails with the error. Pointing out that something fails is not “going round in circles”. My “direct” method of re-writing content.xml to set up events works. This (in Python, so far) doesn’t.

import uno
from com.sun.star.beans import PropertyValue
localContext = uno.getComponentContext()
resolver = localContext.ServiceManager.createInstanceWithContext(
    "com.sun.star.bridge.UnoUrlResolver", localContext)
ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;"
        "StarOffice.ComponentContext")
smgr = ctx.ServiceManager
desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
dispatcher = smgr.createInstanceWithContext("com.sun.star.frame.DispatchHelper", ctx)
filepath = r"D:\My Documents\software projects\EclipseWorkspace\lo_base_automation\with_py_macros.odb"
fileUrl = uno.systemPathToFileUrl(os.path.realpath(filepath))
document = desktop.loadComponentFromURL(fileUrl, "_default", 0, ())
form = document.FormDocuments.getByName('test_kernel.invoices')
document.CurrentController.connect()
form_name = 'test_kernel.invoices'
prop1 = PropertyValue(Name='Hidden', Value=True)
# form_doc = document.CurrentController.loadComponentWithArguments(2, form_name, True, prop1)
form_doc = document.CurrentController.loadComponent(2, form_name, True)
prop2 = (PropertyValue(Name='EventType', Value='Script'), 
        PropertyValue(Name='Script', Value='vnd.sun.star.script:my_2base_script.py$on_load_invoice_form?language=Python&location=document'))
form_doc.Events.replaceByName('OnLoad', prop2)
form.store()
form.dispose()

.
OK then use that.

1 Like