/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015 Atlassian Pty Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.java.sip.communicator.impl.history;
import static
net.java.sip.communicator.service.history.HistoryService.DATE_FORMAT;
import java.io.*;
import java.security.*;
import java.text.*;
import java.util.*;
import net.java.sip.communicator.service.history.*;
import net.java.sip.communicator.service.history.records.*;
import org.jitsi.util.xml.XMLUtils;
import org.w3c.dom.*;
import com.google.common.xml.*;
/**
* @author Alexander Pelov
*/
public class HistoryWriterImpl
implements HistoryWriter
{
/**
* Maximum records per file.
*/
public static final int MAX_RECORDS_PER_FILE = 150;
private static final String CDATA_SUFFIX = "_CDATA";
private Object docCreateLock = new Object();
private Object docWriteLock = new Object();
private HistoryImpl historyImpl;
private String[] structPropertyNames;
private Document currentDoc = null;
private String currentFile = null;
private int currentDocElements = -1;
protected HistoryWriterImpl(HistoryImpl historyImpl)
{
this.historyImpl = historyImpl;
HistoryRecordStructure struct = this.historyImpl
.getHistoryRecordsStructure();
this.structPropertyNames = struct.getPropertyNames();
}
public void addRecord(HistoryRecord record)
throws IOException
{
this.addRecord(
record.getPropertyNames(),
record.getPropertyValues(),
record.getTimestamp(),
-1);
}
public void addRecord(String[] propertyValues)
throws IOException
{
addRecord(structPropertyNames, propertyValues, new Date(), -1);
}
public void addRecord(String[] propertyValues, Date timestamp)
throws IOException {
this.addRecord(structPropertyNames, propertyValues, timestamp, -1);
}
/**
* Stores the passed propertyValues complying with the
* historyRecordStructure.
*
* @param propertyValues
* The values of the record.
* @param maxNumberOfRecords the maximum number of records to keep or
* value of -1 to ignore this param.
*
* @throws IOException
*/
public void addRecord(String[] propertyValues,
int maxNumberOfRecords)
throws IOException
{
addRecord(
structPropertyNames,
propertyValues,
new Date(),
maxNumberOfRecords);
}
/**
* Adds new record to the current history document
* when the record property name ends with _CDATA this is removed from the
* property name and a CDATA text node is created to store the text value
*
* @param propertyNames String[]
* @param propertyValues String[]
* @param date Date
* @param maxNumberOfRecords the maximum number of records to keep or
* value of -1 to ignore this param.
* @throws InvalidParameterException
* @throws IOException
*/
private void addRecord(String[] propertyNames,
String[] propertyValues,
Date date,
int maxNumberOfRecords)
throws InvalidParameterException, IOException
{
// Synchronized to assure that two concurrent threads can insert records
// safely.
synchronized (this.docCreateLock)
{
if (this.currentDoc == null
|| this.currentDocElements > MAX_RECORDS_PER_FILE)
{
this.createNewDoc(date, this.currentDoc == null);
}
}
synchronized (this.currentDoc)
{
Node root = this.currentDoc.getFirstChild();
synchronized (root)
{
// if we have setting for max number of records,
// check the number and when exceed them, remove the first one
if( maxNumberOfRecords > -1
&& this.currentDocElements >= maxNumberOfRecords)
{
// lets remove the first one
removeFirstRecord(root);
}
Element elem = createRecord(
this.currentDoc, propertyNames, propertyValues, date);
root.appendChild(elem);
this.currentDocElements++;
}
}
// write changes
synchronized (this.docWriteLock)
{
if(historyImpl.getHistoryServiceImpl().isCacheEnabled())
this.historyImpl.writeFile(this.currentFile);
else
this.historyImpl.writeFile(this.currentFile, this.currentDoc);
}
}
/**
* Creates a record element for the supplied <tt>doc</tt> and populates it
* with the property names from <tt>propertyNames</tt> and corresponding
* values from <tt>propertyValues</tt>. The <tt>date</tt> will be used
* for the record timestamp attribute.
* @param doc the parent of the element.
* @param propertyNames property names for the element
* @param propertyValues values for the properties
* @param date the of creation of the record
* @return the newly created element.
*/
private Element createRecord(Document doc,
String[] propertyNames,
String[] propertyValues,
Date date)
{
Element elem = doc.createElement("record");
SimpleDateFormat sdf
= new SimpleDateFormat(DATE_FORMAT);
elem.setAttribute("timestamp", sdf.format(date));
for (int i = 0; i < propertyNames.length; i++)
{
String propertyName = propertyNames[i];
if(propertyName.endsWith(CDATA_SUFFIX))
{
if (propertyValues[i] != null)
{
propertyName =
propertyName.replaceFirst(CDATA_SUFFIX, "");
Element propertyElement = doc.createElement(propertyName);
Text value = doc.createCDATASection(
XmlEscapers.xmlContentEscaper().escape(
propertyValues[i].replaceAll("\0", " ")
));
propertyElement.appendChild(value);
elem.appendChild(propertyElement);
}
}
else
{
if (propertyValues[i] != null)
{
Element propertyElement = doc.createElement(propertyName);
Text value = doc.createTextNode(
XmlEscapers.xmlContentEscaper().escape(
propertyValues[i].replaceAll("\0", " ")
));
propertyElement.appendChild(value);
elem.appendChild(propertyElement);
}
}
}
return elem;
}
/**
* Finds the oldest node by timestamp in current root and deletes it.
* @param root where to search for records
*/
private void removeFirstRecord(Node root)
{
SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT);
NodeList nodes = ((Element)root).getElementsByTagName("record");
Node oldestNode = null;
Date oldestTimeStamp = null;
Node node;
for (int i = 0; i < nodes.getLength(); i++)
{
node = nodes.item(i);
Date timestamp;
String ts
= node.getAttributes().getNamedItem("timestamp").getNodeValue();
try
{
timestamp = sdf.parse(ts);
}
catch (ParseException e)
{
timestamp = new Date(Long.parseLong(ts));
}
if(oldestNode == null
|| (oldestTimeStamp.after(timestamp)))
{
oldestNode = node;
oldestTimeStamp = timestamp;
continue;
}
}
if(oldestNode != null)
root.removeChild(oldestNode);
}
/**
* Inserts a record from the passed <tt>propertyValues</tt> complying with
* the current historyRecordStructure.
* First searches for the file to use to import the record, as files hold
* records with consecutive times and this fact is used for searching and
* filtering records by date. This is why when inserting an old record
* we need to insert it on the correct position.
*
* @param propertyValues The values of the record.
* @param timestamp The timestamp of the record.
* @param timestampProperty the property name for the timestamp of the
* record
*
* @throws IOException
*/
public void insertRecord(
String[] propertyValues, Date timestamp, String timestampProperty)
throws IOException
{
SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT);
Iterator<String> fileIterator
= HistoryReaderImpl.filterFilesByDate(
this.historyImpl.getFileList(), timestamp, null)
.iterator();
String filename = null;
while (fileIterator.hasNext())
{
filename = fileIterator.next();
Document doc = this.historyImpl.getDocumentForFile(filename);
if(doc == null)
continue;
NodeList nodes = doc.getElementsByTagName("record");
boolean changed = false;
Node node;
for (int i = 0; i < nodes.getLength(); i++)
{
node = nodes.item(i);
Element idNode = XMLUtils.findChild(
(Element)node, timestampProperty);
if(idNode == null)
continue;
Node nestedNode = idNode.getFirstChild();
if(nestedNode == null)
continue;
// Get nested TEXT node's value
String nodeValue = nestedNode.getNodeValue();
Date nodeTimeStamp;
try
{
nodeTimeStamp = sdf.parse(nodeValue);
}
catch (ParseException e)
{
nodeTimeStamp = new Date(Long.parseLong(nodeValue));
}
if(nodeTimeStamp.before(timestamp))
continue;
Element newElem = createRecord(
doc, structPropertyNames, propertyValues, timestamp);
doc.getFirstChild().insertBefore(newElem, node);
changed = true;
break;
}
if(changed)
{
// write changes
synchronized (this.docWriteLock)
{
this.historyImpl.writeFile(filename, doc);
}
// this prevents that the current writer, which holds
// instance for the last document he is editing will not
// override our last changes to the document
if(filename.equals(this.currentFile))
{
this.currentDoc = doc;
}
break;
}
}
}
/**
* If no file is currently loaded loads the last opened file. If it does not
* exists or if the current file was set - create a new file.
*
* @param date Date
* @param loadLastFile boolean
*/
private void createNewDoc(Date date, boolean loadLastFile)
{
boolean loaded = false;
if (loadLastFile)
{
Iterator<String> files = historyImpl.getFileList();
String file = null;
while (files.hasNext())
{
file = files.next();
}
if (file != null)
{
this.currentDoc = this.historyImpl.getDocumentForFile(file);
this.currentFile = file;
loaded = true;
}
// if something happened and file was not loaded
// then we must create new one
if(this.currentDoc == null)
{
loaded = false;
}
}
if (!loaded)
{
this.currentFile = Long.toString(date.getTime());
this.currentFile += ".xml";
this.currentDoc = this.historyImpl.createDocument(this.currentFile);
}
// TODO: Assert: Assert.assertNonNull(this.currentDoc,
// "There should be a current document created.");
this.currentDocElements = this.currentDoc.getFirstChild()
.getChildNodes().getLength();
}
/**
* Updates a record by searching for record with idProperty which have
* idValue and updating/creating the property with newValue.
*
* @param idProperty name of the id property
* @param idValue value of the id property
* @param property the property to change
* @param newValue the value of the changed property.
*/
public void updateRecord(String idProperty, String idValue,
String property, String newValue)
throws IOException
{
Iterator<String> fileIterator = this.historyImpl.getFileList();
String filename = null;
while (fileIterator.hasNext())
{
filename = fileIterator.next();
Document doc = this.historyImpl.getDocumentForFile(filename);
if(doc == null)
continue;
NodeList nodes = doc.getElementsByTagName("record");
boolean changed = false;
Node node;
for (int i = 0; i < nodes.getLength(); i++)
{
node = nodes.item(i);
Element idNode = XMLUtils.findChild((Element)node, idProperty);
if(idNode == null)
continue;
Node nestedNode = idNode.getFirstChild();
if(nestedNode == null)
continue;
// Get nested TEXT node's value
String nodeValue = nestedNode.getNodeValue();
if(!nodeValue.equals(idValue))
continue;
Element changedNode =
XMLUtils.findChild((Element)node, property);
if(changedNode != null)
{
Node changedNestedNode = changedNode.getFirstChild();
changedNestedNode.setNodeValue(newValue);
}
else
{
Element propertyElement = this.currentDoc
.createElement(property);
Text value = this.currentDoc
.createTextNode(newValue.replaceAll("\0", " "));
propertyElement.appendChild(value);
node.appendChild(propertyElement);
}
// change the timestamp, to reflect there was a change
SimpleDateFormat sdf
= new SimpleDateFormat(DATE_FORMAT);
((Element)node).setAttribute("timestamp",
sdf.format(new Date()));
changed = true;
break;
}
if(changed)
{
// write changes
synchronized (this.docWriteLock)
{
this.historyImpl.writeFile(filename, doc);
}
// this prevents that the current writer, which holds
// instance for the last document he is editing will not
// override our last changes to the document
if(filename.equals(this.currentFile))
{
this.currentDoc = doc;
}
break;
}
}
}
/**
* Updates history record using given <tt>HistoryRecordUpdater</tt> instance
* to find which is the record to be updated and to get the new values for
* the fields
* @param updater the <tt>HistoryRecordUpdater</tt> instance.
*/
public void updateRecord(HistoryRecordUpdater updater) throws IOException
{
Iterator<String> fileIterator = this.historyImpl.getFileList();
String filename = null;
while (fileIterator.hasNext())
{
filename = fileIterator.next();
Document doc = this.historyImpl.getDocumentForFile(filename);
if(doc == null)
continue;
NodeList nodes = doc.getElementsByTagName("record");
boolean changed = false;
Node node;
for (int i = 0; i < nodes.getLength(); i++)
{
node = nodes.item(i);
updater.setHistoryRecord(createHistoryRecordFromNode(node));
if(!updater.isMatching())
continue;
// change the timestamp, to reflect there was a change
SimpleDateFormat sdf
= new SimpleDateFormat(DATE_FORMAT);
((Element)node).setAttribute("timestamp",
sdf.format(new Date()));
Map<String, String> updates = updater.getUpdateChanges();
for(String nodeName : updates.keySet())
{
Element changedNode =
XMLUtils.findChild((Element)node, nodeName);
if(changedNode != null)
{
Node changedNestedNode = changedNode.getFirstChild();
changedNestedNode.setNodeValue(updates.get(nodeName));
changed = true;
}
}
}
if(changed)
{
// write changes
synchronized (this.docWriteLock)
{
this.historyImpl.writeFile(filename, doc);
}
// this prevents that the current writer, which holds
// instance for the last document he is editing will not
// override our last changes to the document
if(filename.equals(this.currentFile))
{
this.currentDoc = doc;
}
break;
}
}
}
/**
* Creates <tt>HistoryRecord</tt> instance from <tt>Node</tt> object.
* @param node the node
* @return the <tt>HistoryRecord</tt> instance
*/
private HistoryRecord createHistoryRecordFromNode(Node node)
{
HistoryRecordStructure structure
= historyImpl.getHistoryRecordsStructure();
String propertyValues[] = new String[structure.getPropertyCount()];
int i = 0;
for(String propertyName : structure.getPropertyNames())
{
Element childNode = XMLUtils.findChild((Element)node, propertyName);
if(childNode == null)
{
i++;
continue;
}
propertyValues[i] = childNode.getTextContent();
i++;
}
return new HistoryRecord(structure, propertyValues);
}
}