Here at Novaworks, we often get requests for features to add to GoFiler. Our topic this week is a client request that I think is another good example of how to use Legato to do some HTML parsing and modification. The original request was to have a way to detect paragraphs within table cells with underlines (border-bottom property) or underline tags in them. It would be great to remove the underlines from the paragraphs and put them on the table cell with the paragraphs instead. This script will work with two selection modes in an HTML file: you can either drag-select the table cells on which you want to run it, or you can click in a cell and run the script without selecting anything. Our script takes the start of your selection and finds the previous table cell. Then, working from that point until the end of the current cell or your selection, it locates any paragraphs with underlines or underline tags. If it finds them, it removes them and puts them in the table cell instead.
Friday, December 15. 2017
LDC #63: Promoting Underlines To Table Cells
Here’s the completed script file:
#define DEFAULT_BORDER_WIDTH "1pt" #define DEFAULT_BORDER_COLOR "Black" #define DEFAULT_BORDER_STYLE "solid" void run_promote (int f_id, string mode, handle edit_window); string get_cell_underlines (handle sgml, handle edit_object); string get_previous_cell_pos (handle sgml, int sx, int sy); /****************************************/ int setup() { /* Called from Application Startup */ /****************************************/ string fnScript; /* Us */ string item[10]; /* Menu Item */ int rc; /* Return Code */ /* */ item["Code"] = "EXTENSION_PROMOTE_UNDERLINES"; /* Function Code */ item["MenuText"] = "&Promote Underlines To Cells"; /* Menu Text */ item["Description"] = "<B>Promote Underlines To Cells</B>\r\r"; /* Description (long) */ item["Description"]+= "All paragraphs with underlines in tables "; /* * description */ item["Description"]+="have their underlines promoted to the cell"; /* * description */ item["Class"] = "DocumentExtension"; /* add to document toolbar */ fnScript = GetScriptFilename(); /* Get the script filename */ MenuAddFunction(item); /* add menu function */ MenuSetHook(item["Code"], fnScript, "run_promote"); /* Set the Test Hook */ return ERROR_NONE; /* Return value (does not matter) */ } /* end setup */ /****************************************/ void main(){ /* main */ /****************************************/ setup(); /* run setup */ } /* */ /****************************************/ void run_promote(int f_id, string mode, handle edit_window){ /* promote underlines to table cells */ /****************************************/ int ex,ey,sx,sy; /* positional variables */ int s_mode; /* selection mode */ int td_ex, td_ey, td_sx, td_sy; /* table cell positions */ boolean selection; /* true if using linear select */ boolean underline; /* true if we're adding an underline */ string startpos; /* position to start parsing from */ string style[]; /* style of SGML tag */ string border; /* border to use in cell */ dword token; /* token of current element */ string element; /* sgml element */ handle sgml; /* sgml object */ handle edit_object; /* edit object */ /* */ if (mode!="preprocess"){ /* if not running preprocess */ return; /* quit */ } /* */ if (IsWindowHandleValid(edit_window)==false){ /* if not passed a valid file handle */ edit_window = GetActiveEditWindow(); /* get handle to edit window */ } /* */ if(IsError(edit_window)){ /* get active edit window */ MessageBox('x',"This is not an edit window."); /* display error */ return; /* return */ } /* */ edit_object = GetEditObject(edit_window); /* create edit object */ s_mode = GetSelectMode(edit_object); /* get selection mode of object */ if (s_mode!=EDO_NOT_SELECTED){ /* if we have a selection */ selection = true; /* we have a selection of text */ sx = GetSelectStartXPosition(edit_window); /* get the selection start */ sy = GetSelectStartYPosition(edit_window); /* get the selection start */ ex = GetSelectEndXPosition(edit_window); /* get the selection end */ ey = GetSelectEndYPosition(edit_window); /* get the selection end */ } /* */ else{ sx = GetCaretXPosition(edit_object); /* get caret X pos */ sy = GetCaretYPosition(edit_object); /* get caret Y pos */ } /* */ sgml = SGMLCreate(edit_object); /* create sgml object */ if (sx==-1 || sy==-1){ /* if sx or sy is an error */ MessageBox('x',"Cannot get caret position."); /* display error */ return ERROR_NONE; } /* */ startpos = get_previous_cell_pos(sgml, sx, sy); /* get start position to parse from */ if (startpos == ""){ /* if there is no start position */ MessageBox('x',"Cannot find table cell."); /* display error message */ return; /* return */ } /* */ sx = TextToInteger(GetParameter(startpos,"sx")); /* get start x pos */ sy = TextToInteger(GetParameter(startpos,"sy")); /* get start y pos */ element = SGMLNextElement(sgml,sx,sy); /* get the first sgml element */ while(element != ""){ /* while element isn't empty */ token = SGMLGetElementToken(sgml); /* get token of current element */ if (token==HT_TD){ /* check if the tag is Paragraph */ td_sx = SGMLGetItemPosSX(sgml); /* get cell start pos */ td_sy = SGMLGetItemPosSY(sgml); /* get cell start pos */ td_ex = SGMLGetItemPosEX(sgml); /* get cell end pos */ td_ey = SGMLGetItemPosEY(sgml); /* get cell end pos */ style = CSSGetProperties(sgml); /* get style of cell */ border = get_cell_underlines(sgml, edit_object); /* get underlines in the cell */ if (border!=""){ /* if we returned a border */ SGMLSetPosition(sgml,td_sx,td_sy); /* set position to start of table cell */ element = SGMLNextElement(sgml); /* get next element (the table cell) */ style["border-bottom-color"] = GetParameter(border,"color"); /* set new border bottom for cell */ style["border-bottom-style"] = GetParameter(border,"style"); /* set new border bottom for cell */ style["border-bottom-width"] = GetParameter(border,"width"); /* set new border bottom for cell */ CSSSetProperties(sgml,style); /* set SGML styles */ element = SGMLToString(sgml); /* get text of SGML element to write out*/ WriteSegment(edit_object,element,td_sx,td_sy,td_ex,td_ey); /* write out new table cell tag */ SGMLSetPosition(sgml,td_ex,td_ey); /* reset parser position */ } /* */ if (selection == false || td_ey > ey || /* if not linear, or beyond select */ (td_ey==ey && td_ex>ex) ){ /* if not linear, or beyond select */ break; /* break loop */ } /* */ } /* */ element = SGMLNextElement(sgml); /* get the next sgml element */ } /* */ } /* */ /****************************************/ string get_previous_cell_pos(handle sgml, int sx, int sy){ /* get_previous_cell_pos */ /****************************************/ dword token; /* element token */ string element; /* element string */ string positions; /* positions of previous cell */ /* */ element = SGMLPreviousElement(sgml, sx, sy); /* get the previous element */ while(element!=""){ /* while we haven't hit next cell */ token = SGMLGetElementToken(sgml); /* get token of SGML element */ if(token==HT_TD){ /* if we have a table cell */ positions = FormatString("sx:%d;sy:%d", /* get positions string */ SGMLGetItemPosSX(sgml), SGMLGetItemPosSY(sgml)); /* get positions string */ return positions; /* return positions */ } /* */ element = SGMLPreviousElement(sgml); /* get the previous element */ } /* */ return ""; /* return nothing. */ } /* */ /****************************************/ string get_cell_underlines (handle sgml, handle edit_object){ /* get_cell_underline */ /****************************************/ int sx,sy,ex,ey; /* start and end positions of SGML Item */ dword token; /* representation of element as token */ string border_bottom; /* border bottom style */ string border_color; /* new style to use on border bottom */ string border_style; /* new style to use on border bottom */ string border_width; /* new style to use on border bottom */ string style[]; /* CSS styles of tag */ string element; /* text of an SGML element */ /* */ element = "init"; /* ensure loop runs at least once */ while (MakeLowerCase(element)!="</td>" && element!=""){ /* while we are not yet at the end */ element = SGMLNextElement(sgml); /* get the next element */ token = SGMLGetElementToken(sgml); /* get token of current element */ if (token == HT_P){ /* if this is a paragraph */ style = CSSGetProperties(sgml); /* get CSS properties of selected tag */ border_bottom = style["border-bottom-width"]; /* get the border bottom */ if (border_bottom !=""){ /* if there is a border bottom */ sx = SGMLGetItemPosSX(sgml); /* get sx */ sy = SGMLGetItemPosSY(sgml); /* get sy */ ex = SGMLGetItemPosEX(sgml); /* get ex */ ey = SGMLGetItemPosEY(sgml); /* get ey */ border_width = border_bottom; /* set border to return */ border_color = style["border-bottom-color"]; /* set border to return */ border_style = style["border-bottom-style"]; /* set border to return */ style["border-bottom-color"]=""; /* set new border bottom for paragraph */ style["border-bottom-style"]=""; /* set new border bottom for paragraph */ style["border-bottom-width"]=""; /* set new border bottom for paragraph */ CSSSetProperties(sgml,style); /* reset style */ element = SGMLToString(sgml); /* get new text of tag */ WriteSegment(edit_object,element,sx,sy,ex,ey); /* write out new tag */ SGMLSetPosition(sgml,ex,ey); /* reset SGML parser to start of tag */ } /* */ } /* */ if (token == HT_U){ /* if we've got a U tag */ sx = SGMLGetItemPosSX(sgml); /* get sx */ sy = SGMLGetItemPosSY(sgml); /* get sy */ ex = SGMLGetItemPosEX(sgml); /* get ex */ ey = SGMLGetItemPosEY(sgml); /* get ey */ WriteSegment(edit_object,"",sx,sy,ex,ey); /* remove U tag */ SGMLSetPosition(sgml,sx,sy); /* reset parser to where tag used to be */ border_width = DEFAULT_BORDER_WIDTH; /* use default border width */ border_color = DEFAULT_BORDER_COLOR; /* use default border color */ border_style = DEFAULT_BORDER_STYLE; /* use default border style */ } /* */ if (token == HT__U){ /* if we have a close u tag */ sx = SGMLGetItemPosSX(sgml); /* get sx */ sy = SGMLGetItemPosSY(sgml); /* get sy */ ex = SGMLGetItemPosEX(sgml); /* get ex */ ey = SGMLGetItemPosEY(sgml); /* get ey */ WriteSegment(edit_object,"",sx,sy,ex,ey); /* remove close U tag */ SGMLSetPosition(sgml,sx,sy); /* reset parser to where tag used to be */ } /* */ } /* */ if (border_width==""){ /* if there is no border width */ return ""; /* return nothing */ } return FormatString("color:%s;style:%s;width:%s", /* return the new border */ border_color,border_style,border_width); /* return the new border */ }
First, let’s take a look at the defines used in the script. These control the default CSS properties used if there is no defined border, like in an underline tag. If we encounter an underline tag, what kind of border would that represent? A border in CSS has three attributes; width, color, and style. Without all three, the border will not be drawn by the browser. So using these properties, we can define the default look of a border.
#define DEFAULT_BORDER_WIDTH "1pt" #define DEFAULT_BORDER_COLOR "Black" #define DEFAULT_BORDER_STYLE "solid"
The setup and main functions have been described in depth in other blog posts, they are no different in this script file. So lets dig into the first new function, run_promote. It takes three parameters instead of just two; the handle edit_window is a new argument that we used to test our function while writing it. It allows us to specify an edit window on which to operate in the main function. In normal use, this isn’t used.
/****************************************/ void run_promote(int f_id, string mode, handle edit_window){ /* promote underlines to table cells */ /****************************************/ int ex,ey,sx,sy; /* positional variables */ int s_mode; /* selection mode */ int td_ex, td_ey, td_sx, td_sy; /* table cell positions */ boolean selection; /* true if using linear select */ boolean underline; /* true if we're adding an underline */ string startpos; /* position to start parsing from */ string style[]; /* style of SGML tag */ string border; /* border to use in cell */ dword token; /* token of current element */ string element; /* sgml element */ handle sgml; /* sgml object */ handle edit_object; /* edit object */ /* */
First things first, we need to do some validating. If this isn’t in preprocess mode, we can return. If we didn’t pass an edit window to this function, it needs to get the active edit window. In the event it can’t get one, then it needs to display an error and exit. We also need to get the edit object and acquire the selection mode of our edit object. If we have a selection, we need to get the start and end positions of it and set the “selection” flag to true. If there’s no selection, we just need the caret positions for our sx and sy variables. Should either of those be -1, it means there was an error, and we cannot get the position. We should display an error and quit in that case.
if (mode!="preprocess"){ /* if not running preprocess */ return; /* quit */ } /* */ if (IsWindowHandleValid(edit_window)==false){ /* if not passed a valid file handle */ edit_window = GetActiveEditWindow(); /* get handle to edit window */ } /* */ if(IsError(edit_window)){ /* get active edit window */ MessageBox('x',"This is not an edit window."); /* display error */ return; /* return */ } /* */ edit_object = GetEditObject(edit_window); /* create edit object */ s_mode = GetSelectMode(edit_object); /* get selection mode of object */ if (s_mode!=EDO_NOT_SELECTED){ /* if we have a selection */ selection = true; /* we have a selection of text */ sx = GetSelectStartXPosition(edit_window); /* get the selection start */ sy = GetSelectStartYPosition(edit_window); /* get the selection start */ ex = GetSelectEndXPosition(edit_window); /* get the selection end */ ey = GetSelectEndYPosition(edit_window); /* get the selection end */ } /* */ else{ sx = GetCaretXPosition(edit_object); /* get caret X pos */ sy = GetCaretYPosition(edit_object); /* get caret Y pos */ } /* */ if (sx==-1 || sy==-1){ /* if sx or sy is an error */ MessageBox('x',"Cannot get caret position."); /* display error */ return ERROR_NONE; } /* */
We made it through our first string of validations, so it’s time to make an SGML object from our edit object. We can use that to run our function get_previous_cell_pos, which will return a parameter string containing the start positions of our table cell. Legato is currently not able to pass arrays as values, so if we want to pass multiple values easily, putting them into a parameter string and decoding them afterwards is a great way to do so.
If we’re not able to find a table cell to start with, startpos will be blank, so we can display an error message and quit. Otherwise, we can use the GetParameter and TextToInteger SDK functions to get the start coordinates of our first table cell. The SGMLNextElement function will load that cell into our SGML object. While the element is not blank (meaning we haven’t hit the end of the file), we wan to loop through and examine it. We can use the SGMLGetElementToken function to get the token for the element. If the token is HT_TD, it’s a table cell tag, so we want to parse through it. We start by getting the coordinates of the start and end of our cell, as well as the style of it with the CSSGetProperties function. After we have that, running the get_cell_underlines function will obtain the border style, color, and width of the paragraphs or underlines inside the cell. The get_cell_underlines function also removes the bottom borders and underlines it finds while getting the values to use, so there’s no other clean up required afterwards.
sgml = SGMLCreate(edit_object); /* create sgml object */ startpos = get_previous_cell_pos(sgml, sx, sy); /* get start position to parse from */ if (startpos == ""){ /* if there is no start position */ MessageBox('x',"Cannot find table cell."); /* display error message */ return; /* return */ } /* */ sx = TextToInteger(GetParameter(startpos,"sx")); /* get start x pos */ sy = TextToInteger(GetParameter(startpos,"sy")); /* get start y pos */ element = SGMLNextElement(sgml,sx,sy); /* get the first sgml element */ while(element != ""){ /* while element isn't empty */ token = SGMLGetElementToken(sgml); /* get token of current element */ if (token==HT_TD){ /* check if the tag is Paragraph */ td_sx = SGMLGetItemPosSX(sgml); /* get cell start pos */ td_sy = SGMLGetItemPosSY(sgml); /* get cell start pos */ td_ex = SGMLGetItemPosEX(sgml); /* get cell end pos */ td_ey = SGMLGetItemPosEY(sgml); /* get cell end pos */ style = CSSGetProperties(sgml); /* get style of cell */ border = get_cell_underlines(sgml, edit_object); /* get underlines in the cell */
If we did get a border back, we can set our parser position to right before the table cell. Again, the SGMLNextElement function can then load it into our SGML object. We can set the properties in the style array using the response from get_cell_underlines. The response was also a parameter string, so we can use the GetParameter function for each CSS attribute we need to get the color, style, and width. Using the CSSSetProperties function, we can set the style back into our SGML object, and afterwards employ the SGMLToString function to get a string representation of it. Now that we have our new table cell to write out, we can write it with the WriteSegment function and use the SGMLSetPosition function to set our parse position after our newly written tag. Finally, if we don’t have a selection of text we can just break, or if our table cell end position is beyond our selection we can break there, too. Otherwise we just grab the next element and keep going.
if (border!=""){ /* if we returned a border */ SGMLSetPosition(sgml,td_sx,td_sy); /* set position to start of table cell */ element = SGMLNextElement(sgml); /* get next element (the table cell) */ style["border-bottom-color"] = GetParameter(border,"color"); /* set new border bottom for cell */ style["border-bottom-style"] = GetParameter(border,"style"); /* set new border bottom for cell */ style["border-bottom-width"] = GetParameter(border,"width"); /* set new border bottom for cell */ CSSSetProperties(sgml,style); /* set SGML styles */ element = SGMLToString(sgml); /* get text of SGML element to write out*/ WriteSegment(edit_object,element,td_sx,td_sy,td_ex,td_ey); /* write out new table cell tag */ SGMLSetPosition(sgml,td_ex,td_ey); /* reset parser position */ } /* */ if (selection == false || td_ey > ey || /* if not linear, or beyond select */ (td_ey==ey && td_ex>ex) ){ /* if not linear, or beyond select */ break; /* break loop */ } /* */ } /* */ element = SGMLNextElement(sgml); /* get the next sgml element */ } /* */ } /* */
The function get_previous_cell_pos is a pretty simple routine. It simply takes the SGML parser with the starting x and y positions and finds the nearest table cell going backwards. First our function runs the SGMLPreviousElement SDK function to find the previous element, and while the previous element isn’t blank, we check what the previous element was. If it was a table cell, the function builds a parameter string with the start positions of the table cell so our run_promote function knows where to find it. Then we return the positions. If our element wasn’t a table cell, the function keeps going until it finds a table cell or runs out of data to parse, in which case we return nothing.
/****************************************/ string get_previous_cell_pos(handle sgml, int sx, int sy){ /* get_previous_cell_pos */ /****************************************/ dword token; /* element token */ string element; /* element string */ string positions; /* positions of previous cell */ /* */ element = SGMLPreviousElement(sgml, sx, sy); /* get the previous element */ while(element!=""){ /* while we haven't hit next cell */ token = SGMLGetElementToken(sgml); /* get token of SGML element */ if(token==HT_TD){ /* if we have a table cell */ positions = FormatString("sx:%d;sy:%d", /* get positions string */ SGMLGetItemPosSX(sgml), SGMLGetItemPosSY(sgml)); /* get positions string */ return positions; /* return positions */ } /* */ element = SGMLPreviousElement(sgml); /* get the previous element */ } /* */ return ""; /* return nothing. */ } /* */
The last function is get_cell_underlines. Taking an SMGL object and an edit object as parameters, this function parses through a table cell until the cell ends or the file does, removes any underlines or paragraph border bottoms, and returns what style should be put into the table cell. First we initialize the variable element with “init”, to ensure our while loop runs at least once. Then, while our element isn’t a closing table tag and isn’t blank, we can parse through all SGML tags. If we encounter the token HT_P (a paragraph), we want to first get the style properties. If border-bottom-width style is set within it, it means there’s a border, so we want to get the positions of the paragraph tag. Once we have that , we need to find out what the width, color, and style attributes are. Once we have those, we can set the style of the paragraph of those attributes to nothing and use the CSSSetProperties function to set the style back into the SGML object. After that, we can again use the WriteSegment function to write the element back out and the SGMLSetPosition function to reset our parser position.
/****************************************/ string get_cell_underlines(handle sgml, handle edit_object){ /* get_cell_underline */ /****************************************/ int sx,sy,ex,ey; /* start and end positions of SGML Item */ dword token; /* representation of element as token */ string border_bottom; /* border bottom style */ string border_color; /* new style to use on border bottom */ string border_style; /* new style to use on border bottom */ string border_width; /* new style to use on border bottom */ string style[]; /* CSS styles of tag */ string element; /* text of an SGML element */ /* */ element = "init"; /* ensure loop runs at least once */ while (MakeLowerCase(element)!="</td>" && element!=""){ /* while we are not yet at the end */ element = SGMLNextElement(sgml); /* get the next element */ token = SGMLGetElementToken(sgml); /* get token of current element */ if (token == HT_P){ /* if this is a paragraph */ style = CSSGetProperties(sgml); /* get CSS properties of selected tag */ border_bottom = style["border-bottom-width"]; /* get the border bottom */ if (border_bottom !=""){ /* if there is a border bottom */ sx = SGMLGetItemPosSX(sgml); /* get sx */ sy = SGMLGetItemPosSY(sgml); /* get sy */ ex = SGMLGetItemPosEX(sgml); /* get ex */ ey = SGMLGetItemPosEY(sgml); /* get ey */ border_width = border_bottom; /* set border to return */ border_color = style["border-bottom-color"]; /* set border to return */ border_style = style["border-bottom-style"]; /* set border to return */ style["border-bottom-color"]=""; /* set new border bottom for paragraph */ style["border-bottom-style"]=""; /* set new border bottom for paragraph */ style["border-bottom-width"]=""; /* set new border bottom for paragraph */ CSSSetProperties(sgml,style); /* reset style */ element = SGMLToString(sgml); /* get new text of tag */ WriteSegment(edit_object,element,sx,sy,ex,ey); /* write out new tag */ SGMLSetPosition(sgml,ex,ey); /* reset SGML parser to start of tag */ } /* */ } /* */
If we have an underline token, we need to get its start and end coordinates. The WriteSegment and SetSGMLPosition functions can erase it and set the parser position. Then we can set our style variables for our border to our defined default values. If the element is a close U tag, we just need to erase the tag. Quick note: the difference between the open token and the close token for an SGML object is the extra underline in it. So HT_U is the defined Legato token for the underline tag, and HT__U is the defined token for the close underline tag.
if (token == HT_U){ /* if we've got a U tag */ sx = SGMLGetItemPosSX(sgml); /* get sx */ sy = SGMLGetItemPosSY(sgml); /* get sy */ ex = SGMLGetItemPosEX(sgml); /* get ex */ ey = SGMLGetItemPosEY(sgml); /* get ey */ WriteSegment(edit_object,"",sx,sy,ex,ey); /* remove U tag */ SGMLSetPosition(sgml,sx,sy); /* reset parser to where tag used to be */ border_width = DEFAULT_BORDER_WIDTH; /* use default border width */ border_color = DEFAULT_BORDER_COLOR; /* use default border color */ border_style = DEFAULT_BORDER_STYLE; /* use default border style */ } /* */ if (token == HT__U){ /* if we have a close u tag */ sx = SGMLGetItemPosSX(sgml); /* get sx */ sy = SGMLGetItemPosSY(sgml); /* get sy */ ex = SGMLGetItemPosEX(sgml); /* get ex */ ey = SGMLGetItemPosEY(sgml); /* get ey */ WriteSegment(edit_object,"",sx,sy,ex,ey); /* remove close U tag */ SGMLSetPosition(sgml,sx,sy); /* reset parser to where tag used to be */ } /* */ } /* */
After parsing our table tag, we can then check the border width. If it’s blank, it means we encountered no borders and can simply return a blank string. Otherwise, we want to return a parameter string that contains our border color, style, and width for our primary function, so it knows what the set into the table cell.
if (border_width==""){ /* if there is no border width */ return ""; /* return nothing */ } return FormatString("color:%s;style:%s;width:%s", /* return the new border */ border_color,border_style,border_width); /* return the new border */ }
And that finishes it up. This is another good example of the power of Legato to automate simple tasks. It took a few hours to write this script but consider how much time it would save. If you have a document where the tables come in with paragraph underlines instead of cell underlines, for each table you would have to manually set the paragraph border bottom style to nothing and then set the table cell style to whatever border you want. This would be a minute or two operation for each cell, so if you have hundreds of table cells to work on, it can be a daunting (and annoying) task. A couple of hours to write a script here can save a lot more time down the line.
Of course, this script does have a lot of room for improvements. What if you want to make a user interface to edit the default border properties? What if you want it to always use a specific border style instead of using whatever was in the paragraph? What if you want to make it so it checks to make sure all the text in a cell is inside the underline tags, so if it’s just a single word or two underlined the style isn’t moved to the table cell? These are all changes that can be made, but they extend the time it would take to write the script significantly. Like with all Legato scripts, it’s best to find a balance between the features you want to put in and the time it will actually take to add them.
Steven Horowitz has been working for Novaworks for over five years as a technical expert with a focus on EDGAR HTML and XBRL. Since the creation of the Legato language in 2015, Steven has been developing scripts to improve the GoFiler user experience. He is currently working toward a Bachelor of Sciences in Software Engineering at RIT and MCC. |
Additional Resources
Legato Script Developers LinkedIn Group
Primer: An Introduction to Legato
Quicksearch
Categories
Calendar
November '24 | ||||||
---|---|---|---|---|---|---|
Mo | Tu | We | Th | Fr | Sa | Su |
Tuesday, November 19. 2024 | ||||||
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 |