Skip to Content.
Sympa Menu

idok-commit - [idok-commit] idok commit r277 - in trunk/sites/psi/java/ch/psi/idok/gwt/twiki: . client public

idok-commit AT lists.psi.ch

Subject: Commit emails of the iDok project

List archive

[idok-commit] idok commit r277 - in trunk/sites/psi/java/ch/psi/idok/gwt/twiki: . client public


Chronological Thread 
  • From: "AFS account Roman Geus" <geus AT savannah.psi.ch>
  • To: idok-commit AT lists.psi.ch
  • Subject: [idok-commit] idok commit r277 - in trunk/sites/psi/java/ch/psi/idok/gwt/twiki: . client public
  • Date: Thu, 16 Oct 2008 16:58:15 +0200
  • List-archive: <https://lists.web.psi.ch/pipermail/idok-commit/>
  • List-id: Commit emails of the iDok project <idok-commit.lists.psi.ch>

Author: geus
Date: Thu Oct 16 16:58:14 2008
New Revision: 277

Log:
Added IdokSearchTwikiInclude GWT module, including some general purpose code
for accessing the iDok ReST search service using GWT


Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/IdokSearchTwikiInclude.gwt.xml
(contents, props changed)

trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiInclude.java
(contents, props changed)
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchUtil.java
(contents, props changed)
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/RestCallbackHandler.java
(contents, props changed)

trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SimpleResultListWidget.java
(contents, props changed)

trunk/sites/psi/java/ch/psi/idok/gwt/twiki/public/IdokSearchTwikiInclude.css
(contents, props changed)

trunk/sites/psi/java/ch/psi/idok/gwt/twiki/public/IdokSearchTwikiInclude.html
(contents, props changed)
Modified:

trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiMashup.java
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SearchResultItem.java

Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/IdokSearchTwikiInclude.gwt.xml
==============================================================================
--- (empty file)
+++ trunk/sites/psi/java/ch/psi/idok/gwt/twiki/IdokSearchTwikiInclude.gwt.xml
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,36 @@
+<module>
+
+ <!-- Inherit the core Web Toolkit stuff. -->
+ <inherits name='com.google.gwt.user.User'/>
+
+ <!-- Inherit the default GWT style sheet. You can change -->
+ <!-- the theme of your GWT application by uncommenting -->
+ <!-- any one of the following lines. -->
+ <inherits name='com.google.gwt.user.theme.standard.Standard'/>
+ <!-- <inherits name='com.google.gwt.user.theme.chrome.Chrome'/> -->
+ <!-- <inherits name='com.google.gwt.user.theme.dark.Dark'/> -->
+
+ <!-- Inherit the GWT HTTP module. -->
+ <inherits name="com.google.gwt.http.HTTP" />
+
+ <!-- Inherit the GWT XML module. -->
+ <inherits name="com.google.gwt.xml.XML" />
+
+ <!-- Inherit the GWT JSON module. -->
+ <inherits name="com.google.gwt.json.JSON" />
+
+ <!-- Inherit the GWT I18N module. -->
+ <inherits name="com.google.gwt.i18n.I18N"/>
+
+ <!-- Other module inherits -->
+
+ <!-- Specify the app entry point class. -->
+ <entry-point
class='ch.psi.idok.gwt.twiki.client.IdokSearchTwikiInclude'/>
+
+ <!-- Specify the application specific style sheet. -->
+ <stylesheet src='IdokSearchTwikiInclude.css' />
+
+ <!-- User cross-site linker -->
+ <add-linker name="xs" />
+
+</module>

Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiInclude.java
==============================================================================
--- (empty file)
+++
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiInclude.java
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,62 @@
+package ch.psi.idok.gwt.twiki.client;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.Map;
+
+import com.google.gwt.core.client.EntryPoint;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.user.client.Window;
+
+/**
+ * Search the current HTML document for DIV elements with class
"idokInclude".
+ * For each found element, call the iDok ReST search service and render the
+ * search results into the element. The search parameters are obtained from
+ * child DIV elements.
+ *
+ * We fetch all parameters from invisible div elements from the DOM instead
of
+ * from the URL to avoid problems with character encoding: The URL parameter
+ * fetching functions expect UTF-8 encoding, but TWiki sends ISO-8859-1. The
DOM
+ * getInnerText works for any encoding.
+ */
+public class IdokSearchTwikiInclude implements EntryPoint {
+
+ /**
+ * Called after the HTML document has finished loading.
+ *
+ * @see com.google.gwt.core.client.EntryPoint#onModuleLoad()
+ */
+ public void onModuleLoad() {
+
+ // Find div elements with class "idokInclude"
+ ArrayList<Element> idokIncludeDivs = IdokSearchUtil
+ .getElementsByTagAndClassName("div", "idokInclude");
+
+ // Iterate over all includes and process them
+ for (Element e : idokIncludeDivs) {
+ final Element container = e;
+ Map<String, String> params = IdokSearchUtil
+ .buildMapFromChildElements(e);
+ if (params.containsKey("idokRepository")
+ && params.containsKey("idokQueryParam")
+ && params.containsKey("idokMaxHits")) {
+ IdokSearchUtil.doRestCall(params.get("idokRepository"),
params
+ .get("idokQueryParam"), Integer.parseInt(params
+ .get("idokMaxHits")), new RestCallbackHandler() {
+
+ public void onFailure() {
+ Window.alert("ReST call to iDok search service
failed");
+ }
+
+ public void onSuccess(String xmlString) {
+ Iterator<SearchResultItem> it = new
SearchResultIteratorFromXml(
+ xmlString);
+ container.appendChild(new SimpleResultListWidget(it)
+ .getElement());
+ }
+ });
+ }
+ }
+ }
+
+}

Modified:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiMashup.java
==============================================================================
---
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiMashup.java
(original)
+++
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchTwikiMashup.java
Thu Oct 16 16:58:14 2008
@@ -207,8 +207,8 @@

// Format left column: document name
buf.append("<div class=\"twikiLeft\">");
- buf.append("<a href=\"" + getDocumentURL(hit) + "\"><b>"
- + getDocumentNameFromId(hit.getId()) + "</b></a>");
+ buf.append("<a href=\"" + IdokSearchUtil.getDocumentURL(hit)
+ + "\"><b>" + hit.getName() + "</b></a>");
buf.append("</div>");

// Format right column: author
@@ -225,7 +225,7 @@
if (hit.getMeta() != null && fileSizeString != null) {
try {
long fileSize = Long.parseLong(fileSizeString);
- buf.append(formatByteSize(fileSize));
+ buf.append(IdokSearchUtil.formatByteSize(fileSize));
} catch (NumberFormatException e) {
buf.append("unknown size");
}
@@ -286,8 +286,8 @@
for (SearchResultItem hit1 : entry.getValue()) {
if (hit1 != hit)
panel.add(new HTML("<p><a href=\""
- + getDocumentURL(hit1) + "\">r"
- + hit1.getRev() + "</a></p>"));
+ + IdokSearchUtil.getDocumentURL(hit1)
+ + "\">r" + hit1.getRev() + "</a></p>"));
}
}
if (title != null) {
@@ -341,39 +341,4 @@

}

- /**
- * @return Pretty-printed byte size String
- */
- private String formatByteSize(long size) {
- if (size < 10000)
- return "" + size + "b";
- double sizeD = ((double) size) / 1024;
- if (sizeD < 1000)
- return NumberFormat.getFormat("0.0").format(sizeD) + "k";
- sizeD /= 1024;
- if (sizeD < 1000)
- return NumberFormat.getFormat("0.0").format(sizeD) + "m";
- sizeD /= 1024;
- return NumberFormat.getFormat("0.0").format(sizeD) + "g";
- }
-
- /**
- * @return the file name of a iDok docucment given by id
- */
- private String getDocumentNameFromId(String id) {
- int index = id.lastIndexOf('/');
- if (index == -1)
- return id;
- else
- return id.substring(index + 1);
- }
-
- /**
- * @return the URL to the iDok document referenced by the search result
item
- */
- private String getDocumentURL(SearchResultItem hit) {
- return "https://dms02.psi.ch/"; + hit.getRepo() + "/!svn/ver/"
- + hit.getRev() + "/" + hit.getId();
- }
-
}

Added: trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchUtil.java
==============================================================================
--- (empty file)
+++ trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/IdokSearchUtil.java
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,164 @@
+package ch.psi.idok.gwt.twiki.client;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.dom.client.Document;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.dom.client.NodeList;
+import com.google.gwt.http.client.Request;
+import com.google.gwt.http.client.RequestBuilder;
+import com.google.gwt.http.client.RequestCallback;
+import com.google.gwt.http.client.RequestException;
+import com.google.gwt.http.client.Response;
+import com.google.gwt.http.client.URL;
+import com.google.gwt.i18n.client.NumberFormat;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.user.client.Window;
+
+public class IdokSearchUtil {
+
+ /**
+ * Base URL of iDok search ReST interface
+ */
+ // static final String REST_BASE_URL =
+ // "https://dms02.psi.ch/api/v1/search/";;
+ static final String REST_BASE_URL =
"http://dms02.psi.ch:8183/v1/search/";;
+
+ /**
+ * If true, use XMLHttpRequest, else use JSONP for accessing iDok ReST
+ * service
+ */
+ static final boolean USE_XHR = true;
+
+ /**
+ * @return Pretty-printed byte size String
+ */
+ public static String formatByteSize(long size) {
+ if (size < 10000)
+ return "" + size + "b";
+ double sizeD = ((double) size) / 1024;
+ if (sizeD < 1000)
+ return NumberFormat.getFormat("0.0").format(sizeD) + "k";
+ sizeD /= 1024;
+ if (sizeD < 1000)
+ return NumberFormat.getFormat("0.0").format(sizeD) + "m";
+ sizeD /= 1024;
+ return NumberFormat.getFormat("0.0").format(sizeD) + "g";
+ }
+
+ /**
+ * @return the URL to the iDok document referenced by the search result
item
+ */
+ public static String getDocumentURL(SearchResultItem hit) {
+ return "https://dms02.psi.ch/"; + hit.getRepo() + "/!svn/ver/"
+ + hit.getRev() + "/" + hit.getId();
+ }
+
+ /**
+ * Call the iDok search ReST interface and obtain the XML result
+ *
+ * @param repository
+ * the iDok repository in $PRJ/$REPO format, example:
ait/intern
+ * @param query
+ * the iDok query string
+ * @param container
+ */
+ public static void doRestCall(final String repository, final String
query,
+ final int maxHits, final RestCallbackHandler
restCallbackHandler) {
+ String url = REST_BASE_URL + repository + "?q=" + query
+ + "&outputfields=env,meta&num=" + Integer.toString(maxHits);
+
+ if (USE_XHR) {
+ GWT.log("Requesting " + url + " using XHR", null);
+ RequestBuilder builder = new RequestBuilder(RequestBuilder.GET,
URL
+ .encode(url));
+ builder.setHeader("Accept", "text/xml");
+
+ try {
+ builder.sendRequest(null, new RequestCallback() {
+ public void onError(Request request, Throwable
exception) {
+ Window.alert(exception.toString());
+ }
+
+ public void onResponseReceived(Request request,
+ Response response) {
+ if (200 == response.getStatusCode()) {
+
restCallbackHandler.onSuccess(response.getText());
+ } else {
+ // response.getStatusCode()
+ restCallbackHandler.onFailure();
+ }
+ }
+ });
+ } catch (RequestException e) {
+ restCallbackHandler.onFailure();
+ }
+ } else {
+ GWT.log("Requesting " + url + " using JSONP", null);
+ JSONRequest.get(URL.encode(url) + "&callback=",
+ new JSONRequestHandler() {
+ public void onRequestComplete(JavaScriptObject jso) {
+ GWT.log("entering callback", null);
+ if (jso != null) {
+ JSONObject json = new JSONObject(jso);
+ restCallbackHandler.onSuccess(json.get("xml")
+ .toString());
+ } else
+ restCallbackHandler.onFailure();
+ }
+ });
+ }
+ }
+
+ /**
+ * @return an ArrayList of Elements that match the given tag and class
names
+ * in the Document
+ */
+ public static ArrayList<Element> getElementsByTagAndClassName(
+ String tagName, String className) {
+ ArrayList<Element> includeDivs = new ArrayList<Element>();
+ Document doc = Document.get();
+ NodeList<Element> nl = doc.getElementsByTagName(tagName);
+ for (int i = 0; i < nl.getLength(); ++i) {
+ Element e = nl.getItem(i);
+ if (e.getClassName().equals(className))
+ includeDivs.add(e);
+ }
+ return includeDivs;
+ }
+
+ /**
+ * @return a Map containing className = innerText pairs of all direct
child
+ * Elements of parent.
+ */
+ public static Map<String, String> buildMapFromChildElements(Element
parent) {
+ Map<String, String> map = new HashMap<String, String>();
+ Element child = parent.getFirstChildElement();
+ while (child != null) {
+ String className = child.getClassName();
+ if (className != null) {
+ String value = child.getInnerText();
+ if (value != null && value.length() != 0)
+ map.put(className, value);
+ }
+ child = child.getNextSiblingElement();
+ }
+ return map;
+ }
+
+ public static Element getFirstChildByClassName(Element parent,
+ String className) {
+ Element child = parent.getFirstChildElement();
+ while (child != null) {
+ if (child.getClassName().equals(className))
+ return child;
+ child = child.getNextSiblingElement();
+ }
+ return null;
+ }
+
+}

Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/RestCallbackHandler.java
==============================================================================
--- (empty file)
+++
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/RestCallbackHandler.java
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,24 @@
+package ch.psi.idok.gwt.twiki.client;
+
+/**
+ * Interface for callback ReST handlers
+ *
+ * Note that onSuccess, onFailure or no method may be called
+ *
+ * @see ch.psi.idok.gwt.twiki.client.IdokSearchUtil#doRestCall
+ */
+public interface RestCallbackHandler {
+
+ /**
+ * Handle successful ReST call
+ *
+ * @param xmlString XML string received from iDok ReST search service
+ */
+ void onSuccess(String xmlString);
+
+ /**
+ * Handle failed ReST call
+ */
+ void onFailure();
+
+}

Modified:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SearchResultItem.java
==============================================================================
--- trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SearchResultItem.java
(original)
+++ trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SearchResultItem.java
Thu Oct 16 16:58:14 2008
@@ -1,12 +1,21 @@
package ch.psi.idok.gwt.twiki.client;

+import java.util.Date;
import java.util.Map;

+import com.google.gwt.i18n.client.DateTimeFormat;
+
/**
* Class holding one search result item
*/
public class SearchResultItem {

+ /**
+ * Date format for parsing from iDok meta data
+ */
+ final static private DateTimeFormat dataFormatIn = DateTimeFormat
+ .getFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'");
+
private String id;

private String repo;
@@ -58,10 +67,65 @@
}

/**
- * @return the meta data
+ * @return the meta data or null if not available
*/
public Map<String, String> getMeta() {
return meta;
}

+
+
+ /**
+ * @return the author or null if not available
+ */
+ public String getAuthor() {
+ if (meta != null) {
+ return meta.get("svn:entry:last-author");
+ } else
+ return null;
+ }
+
+ /**
+ * @return the commit date or null if not available
+ */
+ public Date getDate() {
+ if (meta != null && meta.containsKey("svn:entry:committed-date")) {
+ try {
+ return
dataFormatIn.parse(meta.get("svn:entry:committed-date"));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ } else
+ return null;
+ }
+
+ /**
+ * @return the document size in bytes
+ */
+ public long getSize() {
+ if (meta != null) {
+ String fileSizeString = meta.get("auto:filesize");
+ if (fileSizeString != null)
+ try {
+ return Long.parseLong(fileSizeString);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ else
+ return -1;
+ } else
+ return -1;
+ }
+
+ /**
+ * @return the file name (without path) of a iDok document given by id
+ */
+ public String getName() {
+ int index = id.lastIndexOf('/');
+ if (index == -1)
+ return id;
+ else
+ return id.substring(index + 1);
+ }
+
}

Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SimpleResultListWidget.java
==============================================================================
--- (empty file)
+++
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/client/SimpleResultListWidget.java
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,94 @@
+package ch.psi.idok.gwt.twiki.client;
+
+import java.util.Date;
+import java.util.Iterator;
+
+import com.google.gwt.i18n.client.DateTimeFormat;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.HTML;
+import com.google.gwt.user.client.ui.InlineLabel;
+import com.google.gwt.user.client.ui.Label;
+
+public class SimpleResultListWidget extends Composite {
+
+ /**
+ * Date format for rendering search results
+ */
+ final static private DateTimeFormat dataFormatOut = DateTimeFormat
+ .getFormat("dd.MM.yyy - HH:mm");
+
+ /**
+ * Constructor
+ */
+ public SimpleResultListWidget(Iterator<SearchResultItem> it) {
+ FlowPanel panel = new FlowPanel();
+ panel.addStyleName("SimpleResultList");
+
+ while (it.hasNext()) {
+ SearchResultItem hit = it.next();
+
+ FlowPanel hitPanel = new FlowPanel();
+ hitPanel.addStyleName("idokSearchHit");
+
+ // Top row
+ HTML topRowHTML = new HTML("<a href=\""
+ + IdokSearchUtil.getDocumentURL(hit) + "\">"
+ + hit.getName() + "</a>");
+ topRowHTML.addStyleName("idokTopRow");
+ hitPanel.add(topRowHTML);
+
+ // Middle row
+ if (hit.getEnv() != null) {
+ Label envLabel = new Label(hit.getEnv());
+ envLabel.addStyleName("idokMiddleRow");
+ hitPanel.add(envLabel);
+ }
+
+ // Bottom row
+ String author = hit.getAuthor();
+ Date date = hit.getDate();
+ long size = hit.getSize();
+
+ FlowPanel bottomRow = new FlowPanel();
+ bottomRow.addStyleName("idokBottomRow");
+
+ HTML urlHTML = new HTML(IdokSearchUtil.getDocumentURL(hit));
+ urlHTML.addStyleName("idokUrl");
+ bottomRow.add(urlHTML);
+
+ if (author != null) {
+ InlineLabel authorLabel = new InlineLabel(author);
+ authorLabel.addStyleName("idokAuthor");
+ bottomRow.add(authorLabel);
+ bottomRow.add(new InlineLabel(" - "));
+ }
+
+ if (size != -1) {
+ InlineLabel sizeLabel = new InlineLabel(IdokSearchUtil
+ .formatByteSize(size));
+ sizeLabel.addStyleName("idokSize");
+ bottomRow.add(sizeLabel);
+ bottomRow.add(new InlineLabel(" - "));
+ }
+
+ if (date != null) {
+ String dateString = dataFormatOut.format(date);
+ InlineLabel dateLabel = new InlineLabel(dateString);
+ dateLabel.addStyleName("idokDate");
+ bottomRow.add(dateLabel);
+ bottomRow.add(new InlineLabel(" - "));
+ }
+
+ InlineLabel revLabel = new InlineLabel("r" + hit.getRev());
+ revLabel.addStyleName("idokRev");
+ bottomRow.add(revLabel);
+ hitPanel.add(bottomRow);
+
+ panel.add(hitPanel);
+ }
+
+ initWidget(panel);
+ }
+
+}

Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/public/IdokSearchTwikiInclude.css
==============================================================================
--- (empty file)
+++
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/public/IdokSearchTwikiInclude.css
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,27 @@
+/** Add css rules here for your application. */
+
+.idokAuthor {
+ color: green;
+ font-style: italic;
+}
+
+.idokDate {
+ color: blue;
+}
+
+.idokSize {
+ color: maroon;
+}
+
+.idokTopRow {
+ font-size: large;
+}
+
+.idokMiddleRow {
+ color: gray;
+}
+
+.idokSearchHit {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
\ No newline at end of file

Added:
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/public/IdokSearchTwikiInclude.html
==============================================================================
--- (empty file)
+++
trunk/sites/psi/java/ch/psi/idok/gwt/twiki/public/IdokSearchTwikiInclude.html
Thu Oct 16 16:58:14 2008
@@ -0,0 +1,39 @@
+<html>
+<head>
+<title>IdokSearchTwikiInclude.html</title>
+<script language='javascript'
+
src='ch.psi.idok.gwt.twiki.IdokSearchTwikiInclude.nocache.js'></script>
+</head>
+<body>
+<h2> <span class="ecpHeading"> iDok search results for query <em>egli
+AND idok</em> in repository <em>ait/intern</em></span></h2>
+Displaying at most
+<em>10</em>
+hits in
+<em>list</em>
+format...
+
+<div class="idokInclude">
+<div class="idokRepository" style="display: none;">ait/intern</div>
+<div class="idokQueryParam" style="display: none;">egli AND idok</div>
+<div class="idokMaxHits" style="display: none;">10</div>
+<div class="idokFormat" style="display: none;">list</div>
+<div class="idokSearchResult"></div>
+</div>
+<h2> <span class="ecpHeading"> iDok search results for query <em>geus</em>
+in repository <em>ait/intern</em></span></h2>
+
+Displaying at most
+<em>5</em>
+hits in
+<em>table</em>
+format...
+<div class="idokInclude">
+<div class="idokRepository" style="display: none;">ait/intern</div>
+<div class="idokQueryParam" style="display: none;">geus</div>
+<div class="idokMaxHits" style="display: none;">5</div>
+<div class="idokFormat" style="display: none;">table</div>
+<div class="idokSearchResult"></div>
+</div>
+</body>
+</html>



  • [idok-commit] idok commit r277 - in trunk/sites/psi/java/ch/psi/idok/gwt/twiki: . client public, AFS account Roman Geus, 10/16/2008

Archive powered by MHonArc 2.6.19.

Top of Page