All Downloads are FREE. Search and download functionalities are using the official Maven repository.

application.ui.preview.EditorController Maven / Gradle / Ivy

package application.ui.preview;


import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.transform.Source;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;

import org.daisy.dotify.common.xml.XMLTools;
import org.daisy.dotify.common.xml.XMLToolsException;
import org.daisy.dotify.common.xml.XmlEncodingDetectionException;
import org.daisy.dotify.studio.api.DocumentPosition;
import org.daisy.dotify.studio.api.ExportAction;
import org.daisy.dotify.studio.api.OpenableEditor;
import org.daisy.dotify.studio.api.SearchCapabilities;
import org.daisy.dotify.studio.api.SearchOptions;
import org.daisy.streamline.api.identity.IdentityProvider;
import org.daisy.streamline.api.media.FileDetails;
import org.daisy.streamline.api.media.InputStreamSupplier;
import org.daisy.streamline.api.validity.ValidationReport;
import org.daisy.streamline.api.validity.ValidatorFactoryMaker;
import org.daisy.streamline.api.validity.ValidatorFactoryMakerService;
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.LineNumberFactory;
import org.fxmisc.richtext.model.StyleSpans;
import org.xml.sax.InputSource;

import application.common.BindingStore;
import application.common.FeatureSwitch;
import application.common.Settings;
import application.l10n.Messages;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableBooleanValue;
import javafx.beans.value.ObservableObjectValue;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane.ScrollBarPolicy;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.FileChooser.ExtensionFilter;
import javafx.stage.Window;

/**
 * Provides an editor controller.
 * @author Joel Håkansson
 *
 */
public class EditorController extends BorderPane implements OpenableEditor {
	private static final Logger logger = Logger.getLogger(EditorController.class.getCanonicalName());
	private static final TransformerFactory XSLT_FACTORY = TransformerFactory.newInstance();
	private static final char BYTE_ORDER_MARK = '\uFEFF';
	private static final int SYNTAX_HIGHLIGHTING_SIZE_LIMIT = 3_100_000; // This value has been tested
	private static final int VALIDATION_SIZE_LIMIT = 10_000_000; // This value is not tested, a lower or higher value may be better
	private static final int SIZE_WARNING_LIMIT = Math.min(SYNTAX_HIGHLIGHTING_SIZE_LIMIT, VALIDATION_SIZE_LIMIT);
	private static final ReadOnlyObjectProperty SEARCH_CAPABILITIES = new SimpleObjectProperty<>(
			new SearchCapabilities.Builder()
			.direction(false)
			.matchCase(true)
			.wrap(true)
			.find(true)
			.replace(true)
			.build()
	);

	@FXML HBox optionsBox;
	@FXML CheckBox wordWrap;
	@FXML CheckBox lineNumbers;
	@FXML CheckBox autosave;
	@FXML Label encodingLabel;
	@FXML Label bomLabel;
	@FXML HBox xmlTools;
	private CodeArea codeArea;
	private VirtualizedScrollPane scrollPane;
	private FileInfo fileInfo = new FileInfo.Builder((File)null).build();
	private ObjectProperty fileDetails = new SimpleObjectProperty<>();
	private ObjectProperty> validationReport = new SimpleObjectProperty<>(Optional.empty());
	private ExecutorService executor;
	private final ReadOnlyBooleanProperty canEmbossProperty;
	private final BooleanProperty isLoadedProperty;
	private final BooleanProperty canSaveProperty;
	private final ReadOnlyStringProperty urlProperty;
	private final SimpleBooleanProperty modifiedProperty;
	private final SimpleBooleanProperty hasCancelledUpdateProperty;
	private final BooleanProperty atMarkProperty;
	private final BindingStore bindings;
	private final boolean readOnly;
	private ChangeWatcher changeWatcher;
	private boolean needsUpdate = false;
	private Long lastSaved = 0l;
	private boolean closing = false;
	//private String hash;

	/**
	 * Creates a new preview controller.
	 */
	public EditorController() {
		this(false);
	}

	public EditorController(boolean readOnly) {
		modifiedProperty = new SimpleBooleanProperty();
		hasCancelledUpdateProperty = new SimpleBooleanProperty(false);
		atMarkProperty = new SimpleBooleanProperty();
		canEmbossProperty = BooleanProperty.readOnlyBooleanProperty(new SimpleBooleanProperty(false));
		isLoadedProperty = new SimpleBooleanProperty(false);
		canSaveProperty = new SimpleBooleanProperty();
		urlProperty = new SimpleStringProperty();
		bindings = new BindingStore();
		this.readOnly = readOnly;
		try {
			FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("Editor.fxml"), Messages.getBundle());
			fxmlLoader.setRoot(this);
			fxmlLoader.setController(this);
			fxmlLoader.load();
		} catch (IOException e) {
			logger.log(Level.WARNING, "Failed to load view", e);
		}
		executor = Executors.newWorkStealingPool();
	}
	
	@FXML void initialize() {
		codeArea = new CodeArea();
		codeArea.setEditable(!readOnly);
		// CodeArea doesn't appear to have a zoomProperty like WebView,
		// instead the css property below is used to change the size of everything
		codeArea.styleProperty().bind(Bindings.format("-fx-font-size: %.2fpt;", Settings.getSettings().zoomLevelProperty().multiply(15)));
		codeArea.getStylesheets().add(this.getClass().getResource("resource-files/codearea.css").toExternalForm());
		
		/*
		codeArea.textProperty().addListener((obs, oldText, newText)-> {
			codeArea.setStyleSpans(0, XMLStyleHelper.computeHighlighting(newText));
		});*/
		codeArea.focusedProperty().addListener((o, ov, nv) -> {
			if (nv && needsUpdate) {
				askForUpdate();
			}
		});
		codeArea.richChanges()
			.filter(ch -> !ch.getInserted().equals(ch.getRemoved()))
			.successionEnds(Duration.ofMillis(500))
			.supplyTask(this::computeHighlightingAsync)
			.awaitLatest(codeArea.richChanges())
			.filterMap(t -> {
				if(t.isSuccess()) {
					return Optional.of(t.get());
				} else {
					t.getFailure().printStackTrace();
					return Optional.empty();
				}
			})
			.subscribe(this::applyHighlighting);
		codeArea.richChanges()
			.filter(ch -> !ch.getInserted().equals(ch.getRemoved()))
			.successionEnds(Duration.ofMillis(1200))
			.supplyTask(this::computeValidationAsync)
			.awaitLatest()
			.filterMap(v -> {
				if (v.isSuccess()) {
					return Optional.of(v.get());
				} else {
					v.getFailure().printStackTrace();
					return Optional.empty();
				}
			})
			.subscribe(v -> validationReport.setValue(v));
		if (FeatureSwitch.AUTOSAVE.isOn() && !readOnly) {
			codeArea.richChanges()
				.filter(ch -> canSaveProperty.get())
				.successionEnds(Duration.ofMillis(500))
				.subscribe(v->autosave());
			autosave.setSelected(Settings.getSettings().shouldAutoSave());
		} else {
			optionsBox.getChildren().remove(autosave);
		}
		atMarkProperty.bind(codeArea.getUndoManager().atMarkedPositionProperty());
		modifiedProperty.bind(bindings.add(atMarkProperty.not().or(hasCancelledUpdateProperty)));
		canSaveProperty.bind(bindings.add(isLoadedProperty.and(modifiedProperty)));
		scrollPane = new VirtualizedScrollPane<>(codeArea);
		lineNumbers.setSelected(Settings.getSettings().shouldShowLineNumbers());
		toggleLineNumbers();
		wordWrap.setSelected(Settings.getSettings().shouldWrapLines());
		toggleWordWrap();
		
		setCenter(scrollPane);
	}
	
	public static boolean supportsFormat(FileDetails editorFormat) {
		// TODO: also support application/epub+zip
		return FormatChecker.isText(editorFormat) || FormatChecker.isHTML(editorFormat) || FormatChecker.isXML(editorFormat);
	}
	
	ValidatorFactoryMakerService factory = ValidatorFactoryMaker.newInstance();
	
	private Task> computeValidationAsync() {
		FileInfo info = fileInfo;
		boolean run = info.isXml() && codeArea.getLength()> task = new Task>() {
			@Override
			protected Optional call() throws Exception {
				if (run) {
					byte[] data = prepareSaveToFile(FileInfo.with(info), info, text);
					
					InputStreamSupplier source = new ByteInputStreamSupplier(fileInfo.getFile().toURI().toASCIIString(), data);
					return factory.newValidator(IdentityProvider.newInstance().identify(source)).map(pv->pv.validate(source));
				}
				return Optional.empty();
			}
		};
		executor.execute(task);
		return task;
	}
	
	private static class ByteInputStreamSupplier implements InputStreamSupplier {
		private final String systemId;
		private final byte[] data;
		private ByteInputStreamSupplier(String systemId, byte[] data) {
			this.systemId = systemId;
			this.data = data;
		}

		@Override
		public InputStream newInputStream() throws IOException {
			return new ByteArrayInputStream(data);
		}

		@Override
		public String getSystemId() {
			return systemId;
		}
	}
	
    private Task>> computeHighlightingAsync() {
    	boolean run = fileInfo.isXml()&&codeArea.getLength()>> task = new Task>>() {
            @Override
            protected StyleSpans> call() throws Exception {
                return run?XMLStyleHelper.computeHighlighting(text):XMLStyleHelper.noStyles(text);
            }
        };
        executor.execute(task);
        return task;
    }
    

	private void autosave() {
		if (autosave.isSelected() && isInSaveableState()) {
			if (logger.isLoggable(Level.FINE)) {
				logger.fine("Autosave...");
			}
			try {
				updateFileInfo(saveToFileSynchronized(fileInfo.getFile(), fileInfo, codeArea.getText()));
				codeArea.getUndoManager().mark();
			} catch (IOException e) {
				logger.log(Level.WARNING, "Autosave failed.", e);
			}
		}
	}

	private synchronized void askForUpdate() {
		if (needsUpdate) {
			needsUpdate = false;
			boolean modified = modifiedProperty.get();
			Platform.runLater(()->{
				Alert alert = new Alert(AlertType.CONFIRMATION, 
						modified?
							Messages.MESSAGE_FILE_MODIFIED_BY_ANOTHER_APPLICATION.localize():
							Messages.MESSAGE_FILE_MODIFIED_BY_ANOTHER_APPLICATION_NO_OVERWRITE.localize(),
						ButtonType.YES, ButtonType.CANCEL);
				Optional res = alert.showAndWait();
				Optional yes = res.filter(v->v.equals(ButtonType.YES));
				if (yes.isPresent()) {
					yes
					.ifPresent(v->{
						load(fileInfo.getFile(), fileInfo.isXml(), false);
					});					
				} else {
					hasCancelledUpdateProperty.set(true);
				}
			});
		}
	}
	
	private synchronized void requestUpdate() {
		if (!readOnly) {
			needsUpdate = true;
			if (codeArea.isFocused()) {
				askForUpdate();
			}
		} else {
			Platform.runLater(()->load(fileInfo.getFile(), fileInfo.isXml(), false));
		}
	}

	private void applyHighlighting(StyleSpans> highlighting) {
		codeArea.setStyleSpans(0, highlighting);
	}

	@Override
	public Consumer open(File f) {
		load(f, FormatChecker.isXML(IdentityProvider.newInstance().identify(f)));
		return v->{};
	}
	/**
	 * Converts and opens a file.
	 * @param f the file
	 * @param xml if the file is xml
	 */
	public void load(File f, boolean xml) {
		load(f, xml, true);
	}

	private void load(File f, boolean xml, boolean resetScroll) {
		if (!xml) {
			optionsBox.getChildren().remove(xmlTools);
		} else {
			if (!optionsBox.getChildren().contains(xmlTools)) {
				optionsBox.getChildren().add(2, xmlTools);
			}
		}
		xmlTools.setVisible(xml);
		FileInfo.Builder builder = new FileInfo.Builder(f);
		try {
			String text = loadData(Files.readAllBytes(f.toPath()), builder, xml);
			if (text.length()>SIZE_WARNING_LIMIT) {
				Platform.runLater(()->{
					Alert alert = new Alert(AlertType.WARNING,
							Messages.MESSAGE_WARNING_OPENING_LARGE_FILE_IN_EDITOR.localize(text.length()),
							ButtonType.OK);
					alert.showAndWait();
				});
			}
			codeArea.setEditable(!readOnly);
			codeArea.replaceText(0, codeArea.getLength(), text);
			if (fileInfo==null || !f.equals(fileInfo.getFile())) {
				codeArea.getUndoManager().forgetHistory();
			}
			codeArea.getUndoManager().mark();
			codeArea.selectRange(0, 0);
			if (resetScroll) {
				codeArea.scrollToPixel(Point2D.ZERO);
			}
			isLoadedProperty.set(true);
		} catch (IOException | XmlEncodingDetectionException e) {
			logger.warning("Failed to read: " + f);
			isLoadedProperty.set(false);
		} finally {
			this.fileInfo = builder.build();
			updateFileInfo(this.fileInfo);
			// Watch document
			if (changeWatcher!=null) {
				changeWatcher.stop();
			}
			changeWatcher = new ChangeWatcher(f);
			Thread th = new Thread(changeWatcher);
			th.setDaemon(true);
			th.start();
		}
	}
	
	static String loadData(byte[] data, FileInfo.Builder builder, boolean xml) throws IOException, XmlEncodingDetectionException {
		builder.xml(xml);
		Charset encoding;
		if (xml) {
			//TODO: Ask if there is an encoding mismatch
			encoding = Charset.forName(XMLTools.detectXmlEncoding(data));
		} else {
			encoding = XMLTools.detectBomEncoding(data).orElse(StandardCharsets.UTF_8);
		}
		builder.charset(encoding);
		String text = new String(data, encoding);
		if (!text.isEmpty() && text.charAt(0)==BYTE_ORDER_MARK) {
			builder.bom(true);
			text = text.substring(1);
		} else {
			builder.bom(false);
		}
		return text;
	}

	@FXML void toggleWordWrap() {
		scrollPane.setHbarPolicy(wordWrap.isSelected()?ScrollBarPolicy.NEVER:ScrollBarPolicy.AS_NEEDED);
		codeArea.setWrapText(wordWrap.isSelected());
	}

	@FXML void toggleLineNumbers() {
		if (lineNumbers.isSelected()) {
			codeArea.setParagraphGraphicFactory(LineNumberFactory.get(codeArea));
		} else {
			codeArea.setParagraphGraphicFactory(null);
		}
	}
	
	@FXML void toggleAutosave() {
		if (autosave.isSelected()) {
			// run autosave manually in case there are unsaved changes,
			// since it is only run when edits are made
			autosave();
		}
	}
	
	@FXML void correctFormatting() {
		if (fileInfo.isXml()) {
			try {
				FileInfo.Builder builder = FileInfo.with(fileInfo);
				Source source = new StreamSource(new ByteArrayInputStream(prepareSaveToFile(builder, fileInfo, codeArea.getText())));
				source.setSystemId(fileInfo.getFile().toURI().toASCIIString());
				ByteArrayOutputStream result = new ByteArrayOutputStream();
				XMLTools.transform(source, new StreamResult(result), this.getClass().getResource("resource-files/pretty-print.xsl"), Collections.emptyMap(), XSLT_FACTORY);
				codeArea.replaceText(0, codeArea.getLength(), loadData(result.toByteArray(), builder, fileInfo.isXml()));
			} catch (XMLToolsException | IOException | XmlEncodingDetectionException e) {
				//TODO: show alert
				e.printStackTrace();
			}
		}
	}
	
	@Override
	public boolean findNext(String text, SearchOptions opts) {
		int pos = codeArea.getCaretPosition();
		Pattern pattern = Pattern.compile(
				(opts.shouldMatchCase()?"":"(?i)")
				+Pattern.quote(text)
				);
		Matcher m = pattern.matcher(codeArea.getText());
		boolean wrap = false;
		if (m.find(pos) || (opts.shouldWrapAround() && (wrap=m.find(0)))) {
			int s = m.start();
			int e = m.end();
			if (wrap && s > pos) {
				// no more matches;
				return false;
			}
			codeArea.selectRange(s, e);
			codeArea.showParagraphInViewport(codeArea.getCurrentParagraph());
			return true;
		}
		return false;
	}
	
	@Override
	public void replace(String replace) {
		if (codeArea.getSelection().getLength()>0) {
			codeArea.replaceSelection(replace);
		}
	}

	@Override
	public void save() {
		try {
			if (confirmSave()) {
				updateFileInfo(saveToFileSynchronized(fileInfo.getFile(), fileInfo, codeArea.getText()));
				codeArea.getUndoManager().mark();
			}
		} catch (IOException e) {
			logger.warning("Failed to write: " + fileInfo.getFile());
		}
	}

	@Override
	public boolean saveAs(File f) throws IOException {
		if (confirmSave()) {
			updateFileInfo(saveToFileSynchronized(f, fileInfo, codeArea.getText()));
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Confirms the save action with the user if needed. Specifically, if the file is XML and the contents is not well-formed.
	 * @return returns true if save should proceed.
	 */
	private boolean confirmSave() {
		if (!isInSaveableState()) {
			Alert alert = new Alert(AlertType.WARNING, Messages.MESSAGE_CONFIRM_SAVE_MALFORMED_XML.localize(), ButtonType.YES, ButtonType.CANCEL);
			Optional res = alert.showAndWait();
			return res.map(v->v.equals(ButtonType.YES)).orElse(false);
		} else {
			return true;
		}
	}
	
	private boolean isInSaveableState() {
		try {
			return !(fileInfo.isXml() && !XMLTools.isWellformedXML(new InputSource(new ByteArrayInputStream(prepareSaveToFile(FileInfo.with(fileInfo), fileInfo, codeArea.getText())))));
		} catch (XMLToolsException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
		return false;
	}
	
	private void updateFileInfo(FileInfo fileInfo) {
		this.fileInfo = fileInfo;
		fileDetails.set(IdentityProvider.newInstance().identify(fileInfo.getFile()));
		encodingLabel.setText(fileInfo.getCharset().name());
		bomLabel.setText(fileInfo.hasBom()?"BOM":"");
		hasCancelledUpdateProperty.set(false);
	}
	
	FileInfo saveToFileSynchronized(File f, FileInfo fileInfo, String text) throws IOException {
		synchronized (lastSaved) {
			FileInfo ret = saveToFile(f, fileInfo, text);
			lastSaved = fileInfo.getFile().lastModified();
			return ret;
		}
	}

	static FileInfo saveToFile(File f, FileInfo fileInfo, String text) throws IOException {
		FileInfo.Builder builder = FileInfo.with(fileInfo);
		builder.file(f);
		byte[] bytes = prepareSaveToFile(builder, fileInfo, text);
		// Creates a temporary file in the same directory as the target file, because renameTo might fail if
		// the new location is on a different file system.
		File ft = File.createTempFile("save", ".tmp", f.getParentFile());
		Files.write(ft.toPath(), bytes);
		try {
			Files.move(ft.toPath(), f.toPath(), StandardCopyOption.REPLACE_EXISTING);
		} catch (IOException e) {
			Platform.runLater(()->{
				Alert alert = new Alert(AlertType.WARNING, Messages.ERROR_FAILED_TO_WRITE_TO_FILE.localize(f.getName()), ButtonType.OK);
				alert.showAndWait();
			});
		}
		return builder.build();
	}
	
	// TODO: This method has a side effect: it modifies the builder
	static byte[] prepareSaveToFile(FileInfo.Builder builder, FileInfo fileInfo, String text) throws IOException {
		Charset charset = StandardCharsets.UTF_8;
		Optional _encoding;
		if (fileInfo.isXml() && (_encoding = XMLTools.getDeclaredEncoding(text)).isPresent()) {
			String encoding = _encoding.get();
			try {
				charset = Charset.forName(encoding);
			} catch (Exception e) {
				Platform.runLater(()-> {
					Alert alert = new Alert(AlertType.ERROR, Messages.ERROR_UNSUPPORTED_XML_ENCODING.localize(encoding), ButtonType.OK);
					alert.showAndWait();
				});
				return null;
			}
			if (StandardCharsets.UTF_16.equals(charset)) {
				// UTF-16 will append a BOM by itself
				builder.bom(true);
			} else if (fileInfo.hasBom() && (isStandardUnicodeCharset(charset) || isUtf32Charset(encoding))) {
				// Add BOM if the original file had it and the new encoding is a unicode charset
				text = BYTE_ORDER_MARK + text;
				builder.bom(true);
			} else {
				builder.bom(false);
			}
		} else {
			// Text file, or an XML-file without a declaration
			charset = fileInfo.getCharset();
			if (StandardCharsets.UTF_16.equals(charset)) {
				// UTF-16 will append a BOM by itself
				builder.bom(true);
			} else if (	(StandardCharsets.UTF_8.equals(charset) && fileInfo.hasBom()) ||
						(!StandardCharsets.UTF_8.equals(charset) && isStandardUnicodeCharset(charset) || isUtf32Charset(charset.name())) ) {
				// For text files, all unicode encodings require a BOM (unless it's utf-8)
				text = BYTE_ORDER_MARK + text;
				builder.bom(true);
			} else {
				builder.bom(false);
			}
		}
		builder.charset(charset);
		return text.getBytes(charset);
	}
	
	private static boolean isStandardUnicodeCharset(Charset charset) {
		return StandardCharsets.UTF_8.equals(charset) || StandardCharsets.UTF_16.equals(charset) || StandardCharsets.UTF_16LE.equals(charset)
				|| StandardCharsets.UTF_16BE.equals(charset);
	}
	
	private static boolean isUtf32Charset(String encoding) {
		return encoding.toLowerCase().startsWith("utf-32");
	}

	@Override
	public void closing() {
		closing = true;
		executor.shutdown();
	}

	@Override
	public List getSaveAsFilters() {
		if (fileInfo.getFile()!=null) {
			String name = fileInfo.getFile().getName();
			int dot = name.lastIndexOf('.');			
			if (dot>=0 && dot fileDetails() {
		return fileDetails;
	}

	@Override
	public ObservableObjectValue> validationReport() {
		return validationReport;
	}

	@Override
	public boolean scrollTo(DocumentPosition msg) {
		if (msg.getLineNumber()>-1) {
			codeArea.moveTo(msg.getLineNumber()-1, Math.max(msg.getColumnNumber()-1, 0));
			codeArea.requestFollowCaret();
			return true;
		} else {
			return false;
		}
	}

	@Override
	public ObservableObjectValue searchCapabilities() {
		return SEARCH_CAPABILITIES;
	}

	@Override
	public String getSelectedText() {
		return codeArea.getSelectedText();
	}

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy