Bug 765285 - Include last 3 days of crash IDs in about:support. r=Mossop,dolske
--- a/toolkit/content/aboutSupport.js
+++ b/toolkit/content/aboutSupport.js
@@ -1,16 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("resource://gre/modules/Troubleshoot.jsm");
+Components.utils.import("resource://gre/modules/PluralForm.jsm");
Components.utils.import("resource://gre/modules/ResetProfile.jsm");
window.addEventListener("load", function onload(event) {
window.removeEventListener("load", onload, false);
Troubleshoot.snapshot(function (snapshot) {
for (let prop in snapshotFormatters)
snapshotFormatters[prop](snapshot[prop]);
});
@@ -27,16 +28,83 @@ let snapshotFormatters = {
$("useragent-box").textContent = data.userAgent;
$("supportLink").href = data.supportURL;
let version = data.version;
if (data.vendor)
version += " (" + data.vendor + ")";
$("version-box").textContent = version;
},
+#ifdef MOZ_CRASHREPORTER
+ crashes: function crashes(data) {
+ let strings = stringBundle();
+ let daysRange = Troubleshoot.kMaxCrashAge / (24 * 60 * 60 * 1000);
+ $("crashes-title").textContent =
+ PluralForm.get(daysRange, strings.GetStringFromName("crashesTitle"))
+ .replace("#1", daysRange);
+ let reportURL;
+ try {
+ reportURL = Services.prefs.getCharPref("breakpad.reportURL");
+ // Ignore any non http/https urls
+ if (!/^https?:/i.test(reportURL))
+ reportURL = null;
+ }
+ catch (e) { }
+ if (!reportURL) {
+ $("crashes-noConfig").style.display = "block";
+ $("crashes-noConfig").classList.remove("no-copy");
+ return;
+ }
+ else {
+ $("crashes-allReports").style.display = "block";
+ $("crashes-allReports").classList.remove("no-copy");
+ }
+
+ if (data.pending > 0) {
+ $("crashes-allReportsWithPending").textContent =
+ PluralForm.get(data.pending, strings.GetStringFromName("pendingReports"))
+ .replace("#1", data.pending);
+ }
+
+ let dateNow = new Date();
+ $.append($("crashes-tbody"), data.submitted.map(function (crash) {
+ let date = new Date(crash.date);
+ let timePassed = dateNow - date;
+ let formattedDate;
+ if (timePassed >= 24 * 60 * 60 * 1000)
+ {
+ let daysPassed = Math.round(timePassed / (24 * 60 * 60 * 1000));
+ let daysPassedString = strings.GetStringFromName("crashesTimeDays");
+ formattedDate = PluralForm.get(daysPassed, daysPassedString)
+ .replace("#1", daysPassed);
+ }
+ else if (timePassed >= 60 * 60 * 1000)
+ {
+ let hoursPassed = Math.round(timePassed / (60 * 60 * 1000));
+ let hoursPassedString = strings.GetStringFromName("crashesTimeHours");
+ formattedDate = PluralForm.get(hoursPassed, hoursPassedString)
+ .replace("#1", hoursPassed);
+ }
+ else
+ {
+ let minutesPassed = Math.max(Math.round(timePassed / (60 * 1000)), 1);
+ let minutesPassedString = strings.GetStringFromName("crashesTimeMinutes");
+ formattedDate = PluralForm.get(minutesPassed, minutesPassedString)
+ .replace("#1", minutesPassed);
+ }
+ return $.new("tr", [
+ $.new("td", [
+ $.new("a", crash.id, null, {href : reportURL + crash.id})
+ ]),
+ $.new("td", formattedDate)
+ ]);
+ }));
+ },
+#endif
+
extensions: function extensions(data) {
$.append($("extensions-tbody"), data.map(function (extension) {
return $.new("tr", [
$.new("td", extension.name),
$.new("td", extension.version),
$.new("td", extension.isActive),
$.new("td", extension.id),
]);
@@ -207,20 +275,24 @@ let snapshotFormatters = {
$("prefs-user-js-section").style.display = "";
// Clear the no-copy class
$("prefs-user-js-section").className = "";
},
};
let $ = document.getElementById.bind(document);
-$.new = function $_new(tag, textContentOrChildren, className) {
+$.new = function $_new(tag, textContentOrChildren, className, attributes) {
let elt = document.createElement(tag);
if (className)
elt.className = className;
+ if (attributes) {
+ for (let attrName in attributes)
+ elt.setAttribute(attrName, attributes[attrName]);
+ }
if (Array.isArray(textContentOrChildren))
this.append(elt, textContentOrChildren);
else
elt.textContent = String(textContentOrChildren);
return elt;
};
$.append = function $_append(parent, children) {
--- a/toolkit/content/aboutSupport.xhtml
+++ b/toolkit/content/aboutSupport.xhtml
@@ -147,42 +147,57 @@
&aboutSupport.appBasicsBuildConfig;
</th>
<td>
<a href="about:buildconfig">about:buildconfig</a>
</td>
</tr>
-#ifdef MOZ_CRASHREPORTER
- <tr class="no-copy">
- <th class="column">
- &aboutSupport.appBasicsCrashIDs;
- </th>
-
- <td>
- <a href="about:crashes">about:crashes</a>
- </td>
- </tr>
-#endif
-
<tr class="no-copy">
<th class="column">
&aboutSupport.appBasicsMemoryUse;
</th>
<td>
<a href="about:memory">about:memory</a>
</td>
</tr>
</tbody>
</table>
<!-- - - - - - - - - - - - - - - - - - - - - -->
+#ifdef MOZ_CRASHREPORTER
+
+ <h2 class="major-section" id="crashes-title">
+ &aboutSupport.crashes.title;
+ </h2>
+
+ <table id="crashes-table">
+ <thead>
+ <tr>
+ <th>
+ &aboutSupport.crashes.id;
+ </th>
+ <th>
+ &aboutSupport.crashes.sendDate;
+ </th>
+ </tr>
+ </thead>
+ <tbody id="crashes-tbody">
+ </tbody>
+ </table>
+ <p id="crashes-allReports" class="no-copy" style="display: none">
+ <a href="about:crashes" id="crashes-allReportsWithPending" style="display: block">&aboutSupport.crashes.allReports;</a>
+ </p>
+ <p id="crashes-noConfig" class="no-copy" style="display: none">&aboutSupport.crashes.noConfig;</p>
+
+#endif
+ <!-- - - - - - - - - - - - - - - - - - - - - -->
<h2 class="major-section">
&aboutSupport.extensionsTitle;
</h2>
<table>
<thead>
<tr>
new file mode 100644
--- /dev/null
+++ b/toolkit/crashreporter/CrashReports.jsm
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = [
+ "CrashReports"
+];
+
+this.CrashReports = {
+ pendingDir: null,
+ reportsDir: null,
+ submittedDir: null,
+ getReports: function CrashReports_getReports()
+ {
+ let reports = [];
+
+ try {
+ // Ignore any non http/https urls
+ if (!/^https?:/i.test(Services.prefs.getCharPref("breakpad.reportURL")))
+ return reports;
+ }
+ catch (e) { }
+
+ if (this.submittedDir.exists() && this.submittedDir.isDirectory()) {
+ let entries = this.submittedDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let file = entries.getNext().QueryInterface(Components.interfaces.nsIFile);
+ let leaf = file.leafName;
+ if (leaf.startsWith("bp-") &&
+ leaf.endsWith(".txt")) {
+ let entry = {
+ id: leaf.slice(0, -4),
+ date: file.lastModifiedTime,
+ pending: false
+ };
+ reports.push(entry);
+ }
+ }
+ }
+
+ if (this.pendingDir.exists() && this.pendingDir.isDirectory()) {
+ let uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ let entries = this.pendingDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let file = entries.getNext().QueryInterface(Components.interfaces.nsIFile);
+ let leaf = file.leafName;
+ let id = leaf.slice(0, -4);
+ if (leaf.endsWith(".dmp") && uuidRegex.test(id)) {
+ let entry = {
+ id: id,
+ date: file.lastModifiedTime,
+ pending: true
+ };
+ reports.push(entry);
+ }
+ }
+ }
+
+ // Sort reports descending by date
+ return reports.sort( (a, b) => b.date - a.date);
+ }
+}
+
+function CrashReports_pendingDir()
+{
+ let pendingDir = Services.dirsvc.get("UAppData", Components.interfaces.nsIFile);
+ pendingDir.append("Crash Reports");
+ pendingDir.append("pending");
+ return pendingDir;
+}
+
+function CrashReports_reportsDir()
+{
+ let reportsDir = Services.dirsvc.get("UAppData", Components.interfaces.nsIFile);
+ reportsDir.append("Crash Reports");
+ return reportsDir;
+}
+
+function CrashReports_submittedDir()
+{
+ let submittedDir = Services.dirsvc.get("UAppData", Components.interfaces.nsIFile);
+ submittedDir.append("Crash Reports");
+ submittedDir.append("submitted");
+ return submittedDir;
+}
+
+this.CrashReports.pendingDir = CrashReports_pendingDir();
+this.CrashReports.reportsDir = CrashReports_reportsDir();
+this.CrashReports.submittedDir = CrashReports_submittedDir();
--- a/toolkit/crashreporter/content/crashes.js
+++ b/toolkit/crashreporter/content/crashes.js
@@ -1,18 +1,18 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const Cc = Components.classes;
const Ci = Components.interfaces;
-var reportsDir, submittedDir, pendingDir;
var reportURL;
+Components.utils.import("resource://gre/modules/CrashReports.jsm");
Components.utils.import("resource://gre/modules/CrashSubmit.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");
const buildID = Services.appinfo.appBuildID;
function submitSuccess(dumpid, ret) {
let link = document.getElementById(dumpid);
if (link) {
@@ -50,36 +50,16 @@ function submitPendingReport(event) {
submitError: submitError,
noThrottle: true })) {
link.className = "submitting";
}
event.preventDefault();
return false;
}
-function findInsertionPoint(reports, date) {
- if (reports.length == 0)
- return 0;
-
- var min = 0;
- var max = reports.length - 1;
- while (min < max) {
- var mid = parseInt((min + max) / 2);
- if (reports[mid].date < date)
- max = mid - 1;
- else if (reports[mid].date > date)
- min = mid + 1;
- else
- return mid;
- }
- if (reports[min].date <= date)
- return min;
- return min+1;
-}
-
function populateReportList() {
var prefService = Cc["@mozilla.org/preferences-service;1"].
getService(Ci.nsIPrefBranch);
try {
reportURL = prefService.getCharPref("breakpad.reportURL");
// Ignore any non http/https urls
if (!/^https?:/i.test(reportURL))
@@ -87,67 +67,17 @@ function populateReportList() {
}
catch (e) { }
if (!reportURL) {
document.getElementById("clear-reports").style.display = "none";
document.getElementById("reportList").style.display = "none";
document.getElementById("noConfig").style.display = "block";
return;
}
- var directoryService = Cc["@mozilla.org/file/directory_service;1"].
- getService(Ci.nsIProperties);
-
- reportsDir = directoryService.get("UAppData", Ci.nsIFile);
- reportsDir.append("Crash Reports");
-
- submittedDir = directoryService.get("UAppData", Ci.nsIFile);
- submittedDir.append("Crash Reports");
- submittedDir.append("submitted");
-
- var reports = [];
- if (submittedDir.exists() && submittedDir.isDirectory()) {
- var entries = submittedDir.directoryEntries;
- while (entries.hasMoreElements()) {
- var file = entries.getNext().QueryInterface(Ci.nsIFile);
- var leaf = file.leafName;
- if (leaf.substr(0, 3) == "bp-" &&
- leaf.substr(-4) == ".txt") {
- var entry = {
- id: leaf.slice(0, -4),
- date: file.lastModifiedTime,
- pending: false
- };
- var pos = findInsertionPoint(reports, entry.date);
- reports.splice(pos, 0, entry);
- }
- }
- }
-
- pendingDir = directoryService.get("UAppData", Ci.nsIFile);
- pendingDir.append("Crash Reports");
- pendingDir.append("pending");
-
- if (pendingDir.exists() && pendingDir.isDirectory()) {
- var uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- var entries = pendingDir.directoryEntries;
- while (entries.hasMoreElements()) {
- var file = entries.getNext().QueryInterface(Ci.nsIFile);
- var leaf = file.leafName;
- var id = leaf.slice(0, -4);
- if (leaf.substr(-4) == ".dmp" && uuidRegex.test(id)) {
- var entry = {
- id: id,
- date: file.lastModifiedTime,
- pending: true
- };
- var pos = findInsertionPoint(reports, entry.date);
- reports.splice(pos, 0, entry);
- }
- }
- }
+ let reports = CrashReports.getReports();
if (reports.length == 0) {
document.getElementById("clear-reports").style.display = "none";
document.getElementById("reportList").style.display = "none";
document.getElementById("noReports").style.display = "block";
return;
}
@@ -203,37 +133,37 @@ function clearReports() {
var bundle = bundles.createBundle("chrome://global/locale/crashes.properties");
var prompts = Cc["@mozilla.org/embedcomp/prompt-service;1"].
getService(Ci.nsIPromptService);
if (!prompts.confirm(window,
bundle.GetStringFromName("deleteconfirm.title"),
bundle.GetStringFromName("deleteconfirm.description")))
return;
- var entries = submittedDir.directoryEntries;
+ var entries = CrashReports.submittedDir.directoryEntries;
while (entries.hasMoreElements()) {
var file = entries.getNext().QueryInterface(Ci.nsIFile);
var leaf = file.leafName;
if (leaf.substr(0, 3) == "bp-" &&
leaf.substr(-4) == ".txt") {
file.remove(false);
}
}
- entries = reportsDir.directoryEntries;
+ entries = CrashReports.reportsDir.directoryEntries;
var oneYearAgo = Date.now() - 31586000000;
while (entries.hasMoreElements()) {
var file = entries.getNext().QueryInterface(Ci.nsIFile);
var leaf = file.leafName;
if (leaf.substr(0, 11) == "InstallTime" &&
file.lastModifiedTime < oneYearAgo &&
leaf != "InstallTime" + buildID) {
file.remove(false);
}
}
- entries = pendingDir.directoryEntries;
+ entries = CrashReports.pendingDir.directoryEntries;
while (entries.hasMoreElements()) {
entries.getNext().QueryInterface(Ci.nsIFile).remove(false);
}
document.getElementById("clear-reports").style.display = "none";
document.getElementById("reportList").style.display = "none";
document.getElementById("noReports").style.display = "block";
}
--- a/toolkit/crashreporter/moz.build
+++ b/toolkit/crashreporter/moz.build
@@ -68,16 +68,17 @@ SOURCES += [
]
if CONFIG['OS_ARCH'] == 'Darwin':
SOURCES += [
'mac_utils.mm',
]
EXTRA_JS_MODULES += [
+ 'CrashReports.jsm',
'CrashSubmit.jsm',
'KeyValueParser.jsm',
]
LIBXUL_LIBRARY = True
MSVC_ENABLE_PGO = True
--- a/toolkit/locales/en-US/chrome/global/aboutSupport.dtd
+++ b/toolkit/locales/en-US/chrome/global/aboutSupport.dtd
@@ -6,16 +6,26 @@
<!-- LOCALIZATION NOTE (aboutSupport.pageSubtitle): don't change the 'supportLink' id. -->
<!ENTITY aboutSupport.pageSubtitle "
This page contains technical information that might be useful when you're
trying to solve a problem. If you are looking for answers to common questions
about &brandShortName;, check out our <a id='supportLink'>support website</a>.
">
+<!ENTITY aboutSupport.crashes.title "Crash Reports">
+<!-- LOCALIZATION NOTE (aboutSupport.crashes.id):
+This is likely the same like id.heading in crashes.dtd. -->
+<!ENTITY aboutSupport.crashes.id "Report ID">
+<!-- LOCALIZATION NOTE (aboutSupport.crashes.id):
+This is likely the same like date.heading in crashes.dtd. -->
+<!ENTITY aboutSupport.crashes.sendDate "Submitted">
+<!ENTITY aboutSupport.crashes.allReports "All Crash Reports">
+<!ENTITY aboutSupport.crashes.noConfig "This application has not been configured to display crash reports.">
+
<!ENTITY aboutSupport.extensionsTitle "Extensions">
<!ENTITY aboutSupport.extensionName "Name">
<!ENTITY aboutSupport.extensionEnabled "Enabled">
<!ENTITY aboutSupport.extensionVersion "Version">
<!ENTITY aboutSupport.extensionId "ID">
<!ENTITY aboutSupport.appBasicsTitle "Application Basics">
<!ENTITY aboutSupport.appBasicsName "Name">
@@ -30,17 +40,16 @@
<!-- LOCALIZATION NOTE (aboutSupport.appBasicsProfileDirWinMac):
This is the Windows- and Mac-specific variant of aboutSupport.appBasicsProfileDir.
Windows/Mac use the term "Folder" instead of "Directory" -->
<!ENTITY aboutSupport.appBasicsProfileDirWinMac "Profile Folder">
<!ENTITY aboutSupport.appBasicsEnabledPlugins "Enabled Plugins">
<!ENTITY aboutSupport.appBasicsBuildConfig "Build Configuration">
<!ENTITY aboutSupport.appBasicsUserAgent "User Agent">
-<!ENTITY aboutSupport.appBasicsCrashIDs "Crash Reports">
<!ENTITY aboutSupport.appBasicsMemoryUse "Memory Use">
<!ENTITY aboutSupport.showDir.label "Open Directory">
<!-- LOCALIZATION NOTE (aboutSupport.showMac.label): This is the Mac-specific
variant of aboutSupport.showDir.label. This allows us to use the preferred
"Finder" terminology on Mac. -->
<!ENTITY aboutSupport.showMac.label "Show in Finder">
<!-- LOCALIZATION NOTE (aboutSupport.showWin.label): This is the Windows-specific
--- a/toolkit/locales/en-US/chrome/global/aboutSupport.properties
+++ b/toolkit/locales/en-US/chrome/global/aboutSupport.properties
@@ -1,12 +1,37 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+# LOCALIZATION NOTE (downloadsTitleFiles): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of days relevant with relevant crash reports
+crashesTitle=Crash Reports for the Last #1 Day;Crash Reports for the Last #1 Days
+
+# LOCALIZATION NOTE (crashesTimeMinutes): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of minutes (between 1 and 59) which have passed since the crash
+crashesTimeMinutes=#1 minute ago;#1 minutes ago
+
+# LOCALIZATION NOTE (crashesTimeHours): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of hours (between 1 and 23) which have passed since the crash
+crashesTimeHours=#1 hour ago;#1 hours ago
+
+# LOCALIZATION NOTE (crashesTimeDays): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of days (1 or more) which have passed since the crash
+crashesTimeDays=#1 day ago;#1 days ago
+
+# LOCALIZATION NOTE (downloadsTitleFiles): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of pending crash reports
+pendingReports=All Crash Reports (including #1 pending crash in the given time range);All Crash Reports (including #1 pending crashes in the given time range)
+
# LOCALIZATION NOTE In the following strings, "Direct2D", "DirectWrite" and "ClearType"
# are proper nouns and should not be translated. Feel free to leave english strings if
# there are no good translations, these are only used in about:support
# LOCALIZATION NOTE: This can be localized with a more generic term, like
# "Graphics-accelerated Windows". It describes a number of windows, e.g.:
# "GPU Accelerated Windows: 2/2 (Direct3D 9)"
# "GPU Accelerated Windows: 0/2"
--- a/toolkit/modules/Troubleshoot.jsm
+++ b/toolkit/modules/Troubleshoot.jsm
@@ -3,18 +3,19 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
this.EXPORTED_SYMBOLS = [
"Troubleshoot",
];
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+Cu.import("resource://gre/modules/AddonManager.jsm");
+Cu.import("resource://gre/modules/CrashReports.jsm");
Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/AddonManager.jsm");
// We use a preferences whitelist to make sure we only show preferences that
// are useful for support and won't compromise the user's privacy. Note that
// entries are *prefixes*: for example, "accessibility." applies to all prefs
// under the "accessibility.*" branch.
const PREFS_WHITELIST = [
"accessibility.",
"browser.cache.",
@@ -99,16 +100,18 @@ this.Troubleshoot = {
}
catch (err) {
let msg = "Troubleshoot data provider failed: " + name + "\n" + err;
Cu.reportError(msg);
providerDone(name, msg);
}
}
},
+
+ kMaxCrashAge: 3 * 24 * 60 * 60 * 1000, // 3 days
};
// Each data provider is a name => function mapping. When a snapshot is
// captured, each provider's function is called, and it's the function's job to
// generate the provider's data. The function is passed a "done" callback, and
// when done, it must pass its data to the callback. The resulting snapshot
// object will contain a name => data entry for each provider.
let dataProviders = {
@@ -129,16 +132,28 @@ let dataProviders = {
getService(Ci.nsIURLFormatter);
try {
data.supportURL = urlFormatter.formatURLPref("app.support.baseURL");
}
catch (e) {}
done(data);
},
+#ifdef MOZ_CRASHREPORTER
+ crashes: function crashes(done) {
+ let reports = CrashReports.getReports();
+ let now = new Date();
+ let reportsNew = reports.filter(report => (now - report.date < Troubleshoot.kMaxCrashAge));
+ let reportsSubmitted = reportsNew.filter(report => (!report.pending));
+ let reportsPendingCount = reportsNew.length - reportsSubmitted.length;
+ let data = {submitted : reportsSubmitted, pending : reportsPendingCount};
+ done(data);
+ },
+#endif
+
extensions: function extensions(done) {
AddonManager.getAddonsByTypes(["extension"], function (extensions) {
extensions.sort(function (a, b) {
if (a.isActive != b.isActive)
return b.isActive ? 1 : -1;
let lc = a.name.localeCompare(b.name);
if (lc != 0)
return lc;
--- a/toolkit/modules/tests/browser/browser_Troubleshoot.js
+++ b/toolkit/modules/tests/browser/browser_Troubleshoot.js
@@ -95,16 +95,47 @@ const SNAPSHOT_SCHEMA = {
vendor: {
type: "string",
},
supportURL: {
type: "string",
},
},
},
+ crashes: {
+ required: true,
+ type: "object",
+ properties: {
+ pending: {
+ required: true,
+ type: "number",
+ },
+ submitted: {
+ required: true,
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ id: {
+ required: true,
+ type: "string",
+ },
+ date: {
+ required: true,
+ type: "number",
+ },
+ pending: {
+ required: true,
+ type: "boolean",
+ },
+ },
+ },
+ },
+ },
+ },
extensions: {
required: true,
type: "array",
items: {
type: "object",
properties: {
name: {
required: true,