Click here to Skip to main content
14,545,256 members

MovieTracker - HTA for Tracking Movies & TV Shows

Rate this:
3.58 (11 votes)
Please Sign up or sign in to vote.
3.58 (11 votes)
19 May 2020CPOL
HTA application written in Javascript and VBscript for tracking and sorting movies list by their release date
The Movie Tracker app is a simple HTA application written mostly in JavaScript (and partly in VBscript) that tracks a list of movies and/or series that are sorted by their release date. It gets the movie info by parsing an IMDb HTML response based on the URL entered by the user. The data is extracted from the IMDb's JSON script and is stored inside the HTML code itself, also as JSON script.

Introduction

Here is a screenshot of how the Movie Tracker app looks like:

Image 1

The 3rd column (days from/until release) is updated each time the app is opened, and the movies are always sorted by their release date. This lets the users keep track of all the movies/series that they want to see once they are released - whatever is on the top of the list and colored green has already been released; the still-unreleased movies/series remain at the bottom of the list and are colored white.

The idea was to make the app as simple as possible and maintainable in only one file – thus I created an HTA app which is keeping the list of movies/series in a table inside the HTML code as JSON script. Once the app is closed, the list is updated - new JSON is created - and it is saved inside the .hta file on the filesystem. This is additionally the reason a part of the code is in VBscript - because Javascript does not support direct access to the filesystem.

The tables are built from scratch each time the app is loaded, from the list of JSON elements that are saved within the HTML code.

Using the App

The basic app consists of an empty table, an "Add" button, 2 buttons to switch between Movies and Series, and, of course, 2 empty tables - one for each - and, of course, the script part.

Image 2

Only one table is visible at a time; the active table can be switched by using the two buttons under the "Add" button. Whichever button is larger, this is the currently selected (visible) table.

By clicking on the "Add" button, a dialog opens asking for an IMDb URL – so the user should input something like this:

After the user adds a new movie or TV show, it will appear as a new row in the appropriate table. If the active table is different from the type of item that was added, the active table shall automatically be changed to the one where the item was added. The movies/series that have already been released (release date in the past) will be colored green, and they will all be sorted by their release date.

Each table row also contains 2 buttons - a button to refresh the row (the row is deleted and info from IMDb is fetched and parsed again) and a button to remove it. The buttons "Refresh all" and "Remove all" in the table header are used to batch refresh or remove all of the items in the currently active table.

Code Execution

Whenever the app is opened, first the JSON scripts are read, parsed and the tables are populated appropriately.

The JSON objects contain the entire HTML elements that are contained by the <td> elements of the tables. First, a <tr> element is created, then for each JSON pair a <td> element is created, and then the <td> elements are appended into the <tr> element in a given order - the order of the columns is kept in an array:

var colmap = [];
colmap[json_imgurl]=0;
colmap[json_date]=1;
colmap[json_days_left]=2;
colmap[json_url]=3;
colmap[json_description]=4;
colmap[json_button_refreshRow]=5;
colmap[json_button_remove]=6;

This is an example of a JSON object:

{"id":"20191023_tt6450804",
"image":"<img style=\"height: 100px;\" onclick=\"imgClicked()\" 

alt=\"Terminator: Dark Fate\" 

src=\"https://m.media-amazon.com/images/M/MV5BNzhlYjE5MjMtZDJmYy00MGZmLTgwN2MtZGM0NT

k2ZTczNmU5XkEyXkFqcGdeQXVyMTkxNjUyNQ@@._V1_.jpg\">",
"datePublished":"23 October 2019",
"daysLeft":"Released 87 day(s) ago!",
"url":"<a href=\"https://www.imdb.com/title/tt6450804/\">Terminator: Dark Fate</a>",
"description":"Terminator: Dark Fate is a movie starring Linda Hamilton, 
Arnold Schwarzenegger, and Mackenzie Davis. An augmented human and Sarah Connor 
must stop an advanced liquid Terminator, from hunting down a young girl, whose fate is...",
"buttonrefreshRow":"<button onclick=\"refreshRow('20191023_tt6450804')\">Refresh</button>",
"buttonRemove":"<button onclick=\"deleteRow('20191023_tt6450804')\">Remove</button>"}

The ID of each item consists of a release date formatted as "yyyymmdd" and IMDb ID (ie. tt6450804); this is to achieve the possibility of sorting by release date, but also to make sure that the IDs are unique.

The objects from JSON are first read into array slist consisting of 2-element arrays, with the ID as the first element, and JSON object as the second element. slist is then sorted by the IDs (elements with index 0). After that, slist is read in a loop, and JSON objects are parsed and appended (in the sorted order) into the corresponding HTML table.

var slist = [];

getJsonObjects(json).forEach(function(item) {
    if (item!='') {
        id=getJsonValue(item,'id');
        slist.push([id, item]);
    }
});
slist.sort(function(a,b) {return a[0]<b[0]});

Parsing JSON

for(i = 0; i<slist.length; i++) {
        id=slist[i][0];
        obj=slist[i][1];
        tr=document.createElement('tr');
        tr.className = trclass;
        tr.setAttribute('id', id);
        getJsonPairs(obj).forEach(function(pair) {
            if (pair!='') {
                // calculate days remaining
                namevalue=getJsonNameValue(pair);
                if (namevalue[0]=='daysLeft') {
                    yyyymmdd=convertDate(tds[colmap[json_date]].innerHTML);
                    namevalue[1]=getTimeRemaining(yyyymmdd);
                    // movie released
                    if (dateDiff(getDate(yyyymmdd), new Date())<0) {
                        tr.setAttribute('style','background-color: #adff2f;');
                    }
                }
                td=document.createElement('td');
                td.className=unescapeJSON(namevalue[0]);
                td.innerHTML=unescapeJSON(namevalue[1]);
                tds[colmap[td.className]]=td;
            }
        });
        for(j = 0; j <tds.length; j++) {
            tr.appendChild(tds[j]);
        }
        tbody.appendChild(tr);
    }

JSON objects are parsed in the following order.

First, all the JSON objects are retrieved by calling function getJsonObjects:

function getJsonObjects(json) {
        var objs=[];
        var obj='';
        var inside=0;
        var c;
        var json2=Trim2(json);
        do {
            if(json2.length>0) {
                c = json2.substring(0,1);
                json2 = json2.substring(1,json2.length);
            } else {
                break;
            }
            if(c=='}') {
                inside--;
                if(inside==0) {
                    objs.push(obj);
                    obj='';
                }
            }
            if(inside>0) {
                obj=obj+c;
            } else if(inside<0) {
                throw('Invalid JSON syntax!');
                return [];
            }
            if(c=='{') inside++;
        } while(true);
        return objs;
    }

Next, for each object, pairs are extracted by calling the function getJsonPairs:

function getJsonPairs(json) {
        var pairs=[];
        var pair='';
        var inside=0;
        var inpar=false;
        var c;
        var cp='';
        var json2=Trim2(json);
        if(json2.substring(0,1)=='{' && json2.substring(json2.length-1)=='}')
        {
            json2=json2.substring(1,json2.length);
            json2=json2.substring(0, json2.length-1);
        }
        do {
            if(json2.length > 0) {
                c = json2.substring(0,1);
                json2 = json2.substring(1, json2.length);
            } else {
                break;
            }
            if(c=='{' || c=='[') {
                inside++;
            } else if (c=='}' || c==']') {
                inside=inside-1;
            } else if (c=='"' && cp!='\\') {
                inpar=!inpar;
                if(json2.trim()=='') { //last pair
                    pair+=c;
                    c=',';
                }
            }
            if(c==',' && inside==0 && !inpar) {
                //writeLog(pair);
                pairs.push(Trim2(pair));
                pair='';
            } else {
                pair+=c;
            }
            cp=c;
        } while(true);
        return pairs;
    }

Then, for each pair, the function getJsonNameValue is called, which returns an array of 2, where the first element is the name, and the second element is the value:

function getJsonNameValue(json) {
        var namevalue=['',''];
        var c;
        var cp='';
        var inpar=false;
        var json2=Trim2(json);
        do {
            if(json2.length > 0) {
                c = json2.substring(0,1);
                json2 = json2.substring(1);
            } else {
                break;
            }
            if(c=='"' && cp!='\\') {
                inpar=!inpar;
            } else if (c==':' && !inpar) {
                namevalue[1]=json2;
                break;
            }
            namevalue[0]=namevalue[0]+c;
            cp=c;
        } while(true);
        namevalue[0]=Trim2(namevalue[0]);
        namevalue[1]=Trim2(namevalue[1]);
        // get rid of quotes
        if(namevalue[0].substring(0,1)=='"') namevalue[0]=namevalue[0].substring(1);
        if(namevalue[1].substring(0,1)=='"') namevalue[1]=namevalue[1].substring(1);
        if(namevalue[0].substring(namevalue[0].length-1)=='"') 
           namevalue[0]=namevalue[0].substring(0, namevalue[0].length-1);
        if(namevalue[1].substring(namevalue[1].length-1)=='"') 
           namevalue[1]=namevalue[1].substring(0, namevalue[1].length-1);
        return namevalue;
    }

Adding a New Item

When "Add" button is clicked, function add(url) is called.

function add(url) {
    if(dragCheck) return;
    if(url=='') {
        var url=prompt('Enter IMDb URL:','');
    }
    if(url==null || url=='') return;
    var id=getImdbId(url);
    var imdbid=id;
    if(id=='') {
        alert('Invalid URL!');
        writeLog('Invalid URL!');
        return;
    }
    // check if item already exists
    var trs;
    var tb;
    for(cnt=0;cnt<=1;cnt++)
    {
        if(i==0) tb=tbody_m;
        else tb=tbody_s;
        trs=tb.getElementsByClassName(trclass);
        for(i=0;i<trs.length;i++) {
            if (trs[i].getAttribute('id').length!=trs[i].getAttribute('id').
                replace(id,'').length) {
                alert('Item \"'+id+'\" already exists!');
                return;
            }
        }
    }
    // read info from Imdb
    writeLog('add: Reading JSON from IMDb');
    writeLog('---------------------------');
    var imdbJSON;
    try {
        imdbJSON=HttpSearch(url, String.fromCharCode(60)+
        'script type=\"application/ld+json\"'+String.fromCharCode(62),
        String.fromCharCode(60)+'/script'+String.fromCharCode(62));
    }
    catch (err) {
        writeLog('ERROR: '+err);
        alert("Not found!");
        return;
    }
    var ms=getJsonValue(imdbJSON,json_type);
    if (ms==json_type_m) {
        if(active_tbody==tbody_s) change_tab(b_m_id);
    } else {
        if(active_tbody==tbody_m) change_tab(b_s_id);
    }
    writeLog('&nbsp;type='+ms);
    var namevalue;
    var tr, td;
    tr=document.createElement('tr');
    tr.className = trclass;
    var tds = [colmap.length];
    var name, thumbnailUrl;
    getJsonPairs(imdbJSON).forEach(function(pair) {
        namevalue=getJsonNameValue(pair);
        namevalue[0]=unescapeJSON(unicodeToChar(namevalue[0]));
        namevalue[1]=unescapeJSON(unicodeToChar(namevalue[1]));
        switch(namevalue[0]) {
            case json_name:
                name=namevalue[1];
                break;
            case json_url:
                url="https://www.imdb.com"+namevalue[1];
                break;
            case json_description:
                td=document.createElement('td');
                td.className=json_description;
                td.innerHTML=namevalue[1];
                tds[colmap[json_description]]=td;
                // build next episode div (for series)
                if(ms==json_type_s) {
                    var episodeDesc=getNextEpisode(imdbid);
                    var dt;
                    if(episodeDesc[0]=='' && episodeDesc[1]==0) episodeDesc=episodeDesc[3];
                    else {
                        if(episodeDesc[0]!='') dt=episodeDesc[0].substring(6,8) + 
                        '.' + episodeDesc[0].substring(4,6) + '.' + 
                        episodeDesc[0].substring(0,4);
                        else dt='Unknown';
                        episodeDesc='Episode #' + episodeDesc[1].toString() + 
                        ' <b>' + episodeDesc[2] + '</b><br><i>Date: ' + dt + 
                        '</i><br><br>' + episodeDesc[3];
                    }
                    tds[colmap[json_description]].innerHTML += '<br>' + 
                    createCollapsible(episodeDesc);
                    var coll = tds[colmap[json_description]].
                    getElementsByClassName('collapsible')[0];
                    coll.addEventListener('click', function() {
                        this.classList.toggle('collapsed');
                        var div = this.nextElementSibling;
                        if (div.style.maxHeight){
                            div.style.maxHeight = null;
                        } else {
                            div.style.maxHeight = div.scrollHeight + 'px';
                        }
                    });
                }
                writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
                break;
            case json_imgurl:
                thumbnailUrl=namevalue[1];
                break;
            case json_date:
                namevalue[1]=namevalue[1].replace(/-/g,'');
                id=namevalue[1]+'_'+id;
                tr.setAttribute('id', id);
                td=document.createElement('td');
                td.className=json_date;
                td.innerHTML=convertDate(namevalue[1]);
                tds[colmap[json_date]]=td;
                // is movie released?
                if(dateDiff(getDate(namevalue[1]), new Date())<0) {
                    tr.setAttribute('style','background-color: #adff2f;');
                }
                writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
                td=document.createElement('td');
                td.className=json_days_left;
                td.innerHTML=getTimeRemaining(namevalue[1]);
                tds[colmap[json_days_left]]=td;
                writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
                break;
            default:
                break;
        }
    });
    // create url td
    td=document.createElement('td');
    td.className=json_url;
    td.innerHTML=createNameUrl(url, name);
    tds[colmap[json_url]]=td;
    writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
    // create thumbnail td
    td=document.createElement('td');
    td.className=json_imgurl;
    td.innerHTML=createPosterImg(thumbnailUrl,name);
    tds[colmap[json_imgurl]]=td;
    writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
    // create buttonrefreshRow
    td=document.createElement('td');
    td.className=json_button_refreshRow;
    td.innerHTML=createButtonRefresh(id);
    tds[colmap[json_button_refreshRow]]=td;
    writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
    // create buttonRemove
    td=document.createElement('td');
    td.className=json_button_remove;
    td.innerHTML=createButtonRemove(id);
    tds[colmap[json_button_remove]]=td;
    writeLog('&nbsp;&nbsp;'+td.className+'='+td.innerHTML);
    for(j = 0; j<tds.length; j++) {
        tr.appendChild(tds[j]);
    }
    // append the new row to the correct place in the table
    var r_next = null;
    var rows=active_tbody.getElementsByClassName('row');
    for(i=0;i<rows.length;i++) {
        if(rows[i].getAttribute('id') > tr.getAttribute('id')) {
            r_next = rows[i];
            break;
        }
    }
    if(r_next==null) {
        active_tbody.appendChild(tr);
        writeLog('Added '+tr.getAttribute('id'));
    } else {
        r_next.parentNode.insertBefore(tr, r_next);
        writeLog('Added '+tr.getAttribute('id')+' before '+r_next.getAttribute('id'));
    }
    writeLog('&nbsp;');
}

This function is the centerpiece of the entire code. It needs as input an IMDb URL - this URL can be either passed as an argument, or, if that is not the case, is asked from the user with the prompt function.

The add function will first attempt to retrieve the entire IMDb HTML document, using the http request/response (XMLHttpRequest object). Then it will extract the JSON script from the response, parse the JSON, take the necessary name-value pairs, build the <tr> and <td> elements, and add the <tr> into the appropriate table.

Here is a description of some of the functions used in the add function:

  • ImdbSearch(url, startstring, endstring) – This function opens a GET request using the provided URL, looks for the first instance of startstring in the returned stream, then looks for the first subsequent instance of endstring, and returns everything in between. In the previous version, this function was used several times to retrieve each particular info for a movie, now it is used only to retrieve the JSON script; after that, everything else is retrieved by parsing the JSON
  • GetImdbId(url) – Extracts IMDb ID from the URL
  • convertDate(datestring) – Converts between numeric (yyyymmdd) and string (dd monthname yyyy) date representation
  • GetKey(datenum, imdbid) – Creates a key from numeric date (yyyymmdd) and IMDb ID
  • getNextEpisode(imdbid) - Gets next episode info (for series) - see next paragraph!
  • createCollapsible(description) - Creates a collapsible div element which is added to the description column of the series table

Parsing Next Episode Info

There is also an additional piece of information extracted for series - the next episode info. This information is retrieved and extracted in function getNextEpisode, which is called from the add function.

This function retrieves the HTML content from the episode guide from IMDb, i.e.,

By using a DOMParser object, it takes all the necessary information by parsing div elements of class 'info'. It extracts airdate, episode number, title and description for the next episode. It returns an array of strings.

function getNextEpisode(id) {
    writeLog('getNextEpisode for ' + id);
    var parser=new DOMParser();
    var url=getImdbUrl(id) + 'episodes';
    var text=HttpSearch(url,'','');
    var doc=parser.parseFromString(text,'text/html');
    var episodes = [];
    var divs=doc.getElementsByClassName('info');
    var item;
    var defaultDate='99990101';
    var airdate, episodeNumber, title, description;
    for(i=0;i<divs.length;i++) {
        item = divs[i];
        airdate='';
        try {
            airdate=item.getElementsByClassName('airdate')[0].innerHTML.trim();
            if(!isNaN(airdate) && airdate.length==4) { // only year
                airdate=defaultDate;
            }
            else {
                airdate=airDateYYYYMMDD(airdate);
            }
        }
        catch(err) {
        }
        episodeNumber=0;
        try {
            episodeNumber=parseInt(item.getElementsByTagName
                          ('meta')[0].getAttribute('content'),10);
        }
        catch(err) {
        }
        title='';
        try {
            title=item.getElementsByTagName('strong')[0].getElementsByTagName
                  ('a')[0].getAttribute('title');
        }
        catch(err) {
        }
        description='';
        try {
            description=item.getElementsByClassName('item_description')[0];
            if(description.getElementsByTagName('a').length>0) 
               description.removeChild(description.getElementsByTagName('a')[0]);
            description=description.innerHTML.trim();
        }
        catch(err) {
        }
        episodes.push([airdate,episodeNumber,title,description]);
    }
    writeLog(' Found ' + episodes.length + ' episodes');
    if(episodes==null || episodes.length==0) {
        description='Unable to find last episode!';
        writeLog('  ' + description);
        return ['',0,'',description];
    }
    // filter only episodes that are in the future
    var today=(new Date()).toISOString().substring(0,10).replace(/-/g,'');
    var new_episodes=episodes.filter(function(e) {
        return e[0]>=today;
    });
    writeLog('  ' + episodes.length + ' in future');
    if(new_episodes.length>0)
    {
        new_episodes.sort(function(a,b) {return a[0]<b[0]});
        var maxdate=[];
        // check if there are multiple episodes on same date
        for(i=0;i<new_episodes.length;i++) {
            if(new_episodes[i][0]==new_episodes[0][0]) maxdate.push(new_episodes[i]);
        }
        if(maxdate.length==0) maxdate.push(['',0,'','']);
        if(maxdate.length>1) {
            maxdate.sort(function(a,b) {return a[1]<b[1]});
        }
        if(maxdate[0][0]==defaultDate) maxdate[0][0]='';
        writeLog(' date='+maxdate[0][0]);
        writeLog(' episodeNumber='+maxdate[0][1]);
        writeLog(' name='+maxdate[0][2]);
        writeLog(' description='+maxdate[0][3]);
        return maxdate[0];
    }
    else {
        description='No more episodes for this season...';
        writeLog('  ' + description);
        return ['',0,'',description];
    }
}

Deleting an Item

Each tr element created will contain a "Remove" button. If this button is clicked, function deleteRow is called with the id of that particular row. The function simply removes the <tr> child with the given id from the <tbody>.

function deleteRow(id) {
    var tr=document.getElementById(id);
    tr.parentNode.removeChild(tr);
}

Refreshing an Item

Each tr element will also contain a "Refresh" button. This button, when clicked, shall call a function refreshRow. This action is supposed to refresh the data for the particular item - in case something was changed in the meantime, since when it was added or last refreshed. The sub does not do refresh per se, but will rather delete the existing row, and add it again, with fresh data from IMDb.

function refreshRow(id) {
    var tr=document.getElementById(id);
    var url=tr.getElementsByClassName(json_url)[0].getElementsByTagName
               ('a')[0].getAttribute('href');
    deleteRow(id);
    add(url);
}

Save Changes

tbody elements have an event listener for DOMSubtreeModified event - this means that in case anything is changed in the DOM structure of the tbody, function showsave will be called. This function makes the savebutton visible, so that the users are able to save (if they want) the changes (i.e., save what was added, deleted or refreshed).

Even though the savebutton event handler itself is located in the JavaScript part (save function), the actual saving part is being done in the saveChanges sub in the VBscript part. VBscript had to be used for this because JavaScript does not permit direct access to the client filesystem.

The items are saved inside the HTA application itself, inside the corresponding script tags:

<script id="json_movies" type="application/ld+json"></script>


<script id="json_series" type="application/ld+json"></script>

The function that creates JSON:

Function makeJSON(ms)
    makeJSON = ""
    
    Dim tbody
    If ms=json_type_m Then
        Set tbody=tbody_m
    Else
        Set tbody=tbody_s
    End If
    For Each tr In tbody.getelementsbyclassname(trclass)
        makeJSON=makeJSON&"{""id"":"""&tr.getattribute("id")&""","
        For Each td In tr.getelementsbytagname("td")
            makeJSON=makeJSON & """" & escapeJSON(td.className) & """:"""
            makeJSON=makeJSON & escapeJSON(Trim(td.innerhtml))
            makeJSON=makeJSON & ""","
        Next
        makeJSON=Left(makeJSON,Len(makeJSON)-1)
        makeJSON=makeJSON & "}," & Chr(13) & Chr(10)
    Next
    If makeJSON <> "" Then makeJSON=Left(makeJSON,Len(makeJSON)-3)&Chr(13) & Chr(10)
End Function

The HTA app will open its own file from the filesystem, and rewrite its own code. This way, everything is always conveniently kept in a single file - both the app and the data.

The path to the file is obtained by using the document.location.pathname attribute:

path = Right(document.location.pathname,Len(document.location.pathname)-1)

Optional Feature - Read Input File

There is an optional feature built in the app - one can specify an external input file, which, if set up, shall be read each time the app is called.

The input file should contain IMDb IDs in rows, like this:

tt4520988
tt8946378
tt5180504
tt9173418

This feature was meant to falicitate the adding of items to the app "remotely" - since the app is not hosted on a server and thus not available from anywhere, but locally, one could set up a txt file on a cloud service, for example Dropbox, and edit this file whenever and from wherever. Then the app would, once it is opened, pick up anything that was written in this txt.

This code is, obviously, also written in VBscript, since it requires access to the local filesystem.

Optional Feature - Debug Mode

There is also a debug mode built into the app which can be turned on by changing the var debugmode to true. In this case, during the execution of the code, the app will write out some info in a special paragraph at the end of the HTML body.

<P id=dbg style="FONT-SIZE: 10px; FONT-FAMILY: courier; COLOR: red">&nbsp;</P>

History

  • 23rd June, 2019: Initial version
  • 18th January, 2020: 2nd version - Complete code re-design, added refresh option and TV show support, lists stored in JSON
  • 26th January, 2020 - Most of the code transcoded into JavaScript, savebutton added, draggable buttons functionality added, optional functionality external input file added
  • 8th March, 2020 - Added the next episode description (parsing and collapsible div)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marijan Nikic
User Interface Analyst Raiffeisenbank Austria
Croatia Croatia
I acquired Masters degree in computing science on Faculty of Electrical Engineering and Computing in Zagreb, Croatia in 2009. Following my studies, I got a job in a Croatian branch of Austrian-based Central and Eastern European bank Raiffeisen Bank as an MIS (Management information systems) analyst.
I have been working there since 2010, as an IT expert within the Controlling department, maintaining the Oracle's OFSA system, underlying interfaces and databases.
Throughout that time, I have worked with several different technologies, which include SQL & PL/SQL (mostly), Cognos BI, Apparo, Datastage, ODI, Jenkins, ...
I have recently taken a lot of interest in scripting with VBScript and Windows batch scripting. Privately, I am mostly doing Windows Forms and Console apps in Visual Studio, in C#.

Comments and Discussions

 
Questionwhy not localStorage? Pin
raddevus19-May-20 10:13
mvaraddevus19-May-20 10:13 
GeneralMy vote of 5 Pin
Member 1236439027-Jan-20 20:58
MemberMember 1236439027-Jan-20 20:58 
QuestionUnable to Download Pin
Member 1101076018-Sep-19 3:50
MemberMember 1101076018-Sep-19 3:50 
QuestionNice Idea! Pin
Bassam Abdul-Baki26-Aug-19 1:08
professionalBassam Abdul-Baki26-Aug-19 1:08 
QuestionUnable to download Pin
Robert W.Mills26-Jun-19 5:10
MemberRobert W.Mills26-Jun-19 5:10 
AnswerRe: Unable to download Pin
Steve D.30-Jun-19 16:21
MemberSteve D.30-Jun-19 16:21 
AnswerRe: Unable to download Pin
joseguia26-Aug-19 11:30
Memberjoseguia26-Aug-19 11:30 
GeneralRe: Unable to download Pin
Marijan Nikic27-Aug-19 8:34
professionalMarijan Nikic27-Aug-19 8:34 
GeneralRe: Unable to download Pin
Marijan Nikic27-Aug-19 11:41
professionalMarijan Nikic27-Aug-19 11:41 
GeneralRe: Unable to download Pin
Pete Lomax Member 1066450528-Jan-20 5:19
professionalPete Lomax Member 1066450528-Jan-20 5:19 
GeneralRe: Unable to download Pin
Marijan Nikic28-Jan-20 8:58
professionalMarijan Nikic28-Jan-20 8:58 
GeneralRe: Unable to download Pin
Pete Lomax Member 1066450528-Jan-20 19:44
professionalPete Lomax Member 1066450528-Jan-20 19:44 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 25 Jun 2019

Stats

15.2K views
365 downloads
15 bookmarked