From f438e2f15502859191e7feef668b1a63f1ff6633 Mon Sep 17 00:00:00 2001 From: Jonathan Rascher Date: Mon, 24 Jun 2013 20:24:50 -0400 Subject: [PATCH 1/2] Add Bibstro Google Docs bibliography sample --- bibstro/README.markdown | 26 + bibstro/bibstrategy.gs | 711 ++++++++++++++++++++++++ bibstro/citationdialog.html | 115 ++++ bibstro/citationdialog_js.html | 60 ++ bibstro/common.gs | 91 +++ bibstro/configdialog.html | 86 +++ bibstro/configdialog_js.html | 40 ++ bibstro/controller.gs | 467 ++++++++++++++++ bibstro/datastore.gs | 533 ++++++++++++++++++ bibstro/documentmodel.gs | 398 +++++++++++++ bibstro/events.gs | 371 +++++++++++++ bibstro/managereferencessidebar.html | 89 +++ bibstro/managereferencessidebar_js.html | 112 ++++ bibstro/referencedialog.html | 182 ++++++ bibstro/referencedialog_js.html | 120 ++++ 15 files changed, 3401 insertions(+) create mode 100644 bibstro/README.markdown create mode 100644 bibstro/bibstrategy.gs create mode 100644 bibstro/citationdialog.html create mode 100644 bibstro/citationdialog_js.html create mode 100644 bibstro/common.gs create mode 100644 bibstro/configdialog.html create mode 100644 bibstro/configdialog_js.html create mode 100644 bibstro/controller.gs create mode 100644 bibstro/datastore.gs create mode 100644 bibstro/documentmodel.gs create mode 100644 bibstro/events.gs create mode 100644 bibstro/managereferencessidebar.html create mode 100644 bibstro/managereferencessidebar_js.html create mode 100644 bibstro/referencedialog.html create mode 100644 bibstro/referencedialog_js.html diff --git a/bibstro/README.markdown b/bibstro/README.markdown new file mode 100644 index 000000000..ecd62171a --- /dev/null +++ b/bibstro/README.markdown @@ -0,0 +1,26 @@ +Bibstro is a sample bibliography app for [Google Docs][docs] built upon the +[Google Apps Script][apps_script] platform. Bibstro shows how you can extend +Google Docs by adding custom menus, dialogs, and sidebars. Manipulating the +active document to add a live-updating list of sources according to the +citation format selected by the user, Bibstro enhances Google Docs by adding a +custom implementation of a feature not natively supported. + +To get acquainted with Bibstro, make a copy of [this document][sample_doc] and +experiment with the custom bibliography features exposed via the “Bibstro” +menu. You can also view and edit Bibstro’s source code via the “Tools > Script +editor” menu item. + +![][screenshot] + +For an overview of the Google Docs integration we used to build Bibstro, please +see our Google I/O 2013 talk “[Extend Google Docs with Apps Script][io_video]”. +You may also wish to consult our [guide to extending Docs][quickstart], as well +as Apps Script's [API reference][api_reference]. + +[api_reference]: https://developers.google.com/apps-script/reference/document/ +[apps_script]: https://developers.google.com/apps-script/ +[docs]: https://support.google.com/drive/answer/143206?ref_topic=21008&rd=1 +[io_video]: https://www.youtube.com/watch?v=KIiCSdRCqXc +[quickstart]: https://developers.google.com/apps-script/guides/docs +[sample_doc]: https://docs.google.com/document/d/1akzFJ9_5pABrk4NFxLDZX7DymObYfIfGZ3CTZ2CgHSY/edit +[screenshot]: https://googledrive.com/host/0B86sei6ZHtsdVF9iaFd5cTdiNVk/bibstro-readme-screenshot.png diff --git a/bibstro/bibstrategy.gs b/bibstro/bibstrategy.gs new file mode 100644 index 000000000..e66cbda2b --- /dev/null +++ b/bibstro/bibstrategy.gs @@ -0,0 +1,711 @@ +// Copyright 2013 Google Inc. All Rights Reserved. +// +// 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. + +/** + * @fileOverview Google Apps Script and Google Docs demo: Bibstro. Abstract + * bibliography strategy class, together with concrete implementations for the + * supported citation formats. Only the Modern Language Associations (MLA) and + * American Psychological Association (APA) formats are supported in this demo; + * however, support for other formats could be added without too much + * difficulty. + * @author Jonathan Rascher + * @author Saurabh Gupta + */ + +// "Abstract class" definition of the methods a bibliography strategy is +// expected to support. (JavaScript doesn't really have abstract classes, of +// course, and this code isn't being run through the Google Closure compiler +// before execution; however, following the Closure format anyway makes the +// code's intentions a little clearer.) + + + +/** + * Base class constructor for bibliography strategies. Currently does nothing. + * @constructor + */ +var BibStrategy = function() {}; + + +/** + * Returns the internal name of this bibliography strategy. This should be a + * short string like "mla", not a full, human-readable name like "Modern + * Language Association". + * @return {string} + */ +BibStrategy.prototype.getName = function() { + throw new Error('Unimplemented abstract method getName'); +}; + + +/** + * Returns a formatted bibliography entry for a given reference. + * @param {!DataStore.Reference} reference The reference to format. + * @return {!Array.} A list of bibliography entry + * tokens that can either be written into the active Google Docs document or + * formatted as HTML. + */ +BibStrategy.prototype.getBibliographyEntry = function(reference) { + throw new Error('Unimplemented abstract method getBibliographyEntry'); +}; + + +/** + * Returns a reference given a formatted bibliography entry, if possible. The + * returned reference is not guaranteed to be correct under all circumstances. + * Garbage in, garbage out. + * @param {!Array.} tokens A list of bibliography + * entry tokens read from the active Google Docs document. + * @return {DataStore.Reference} A reference model object containing data read + * from the formatted bibliography tokens, or null if the original reference + * contents could not be determined. + */ +BibStrategy.prototype.getReferenceForBibliographyEntry = function(tokens) { + throw new Error( + 'Unimplemented abstract method getReferenceForBibliographyEntry'); +}; + + +/** + * Gets the text of an inline citation for the specified reference. + * @param {!DataStore.Reference} reference The reference to be cited. + * @param {number} startPage The first page included in this citation. + * @param {number} endPage The last page included in this citation. + * @param {boolean} firstMention Whether or not this reference has been cited + * earlier in the document. For certain citation formats (e.g., APA), the + * first citation associated with a given reference must contain information + * that should be omitted in later citations. + * @param {boolean} abbreviateCitation If true, the inserted inline citation + * will be abbreviated somehow. Exactly how depends on the specific citation + * format selected; however, in most formats the author(s) will be omitted + * and only the page number(s) will be included. + * @return {string} + */ +BibStrategy.prototype.getInlineCitationText = function(reference, startPage, + endPage, firstMention, abbreviateCitation) { + return new Error('Unimplemented abstract method getInlineCitationText'); +}; + + +/** + * Attempts, using various heuristics, to determine whether or not the specified + * inline citation is associated with the specified reference. + * @param {!DataStore.Reference} reference The reference we're searching for. + * @param {?string} prevAuthor The author mentioned most recently before the + * inline citation being tested or null if no authors have yet been + * mentioned. + * @param {string} citationStr The plain text contents of the inline citation + * being tested. + * @return {boolean} Whether or not the citation being tested appears to match + * the specified bibliography reference. + */ +BibStrategy.prototype.testInlineCitationMatch = function(reference, prevAuthor, + citationStr) { + // For simplicity's sake, we assume an inline citation consists of either a a + // list of authors, some numeric data (page numbers, publication year, etc.), + // or both. This isn't completely accurate, but it's good enough for a demo. + var citationFields = citationStr.match(/\((\D+)/); + for (var i = 0; i < reference.authors.length; ++i) { + if (citationFields) { + // If the citation has author information, use that to determine if the + // citation matches the reference. This code is rather simplistic, and it + // can give false positives if many reference share one author. A real app + // (rather than a demo) would want to do something fancier here. + if (citationFields[1].indexOf(reference.authors[i].lastName) != -1) { + return true; + } + } else { + // Otherwise, if it citation doesn't contain an author, defer to the + // superclass implementation, which will check if the previous author + // mentioned in the document matches any of this reference's authors. + // Again, this may give false positives if multiple references share one + // or more authors. + if (reference.authors[i].lastName == prevAuthor) { + return true; + } + } + } + return false; +}; + + +/** + * Returns the heading text of this citation format's list of references. This + * is used to find and maintain a live-updating bibliography at the end of the + * Google Docs document. + * @return {string} + */ +BibStrategy.prototype.getBibliographyTitleText = function() { + return new Error('Unimplemented abstract method getBibliographyTitleText'); +}; + + +/** + * Returns a custom comparator for sorting references. A default comparator is + * provided that happens to work reasonably well for both MLA and APA. + * @return {function(!DataStore.Reference, !DataStore.Reference): number} A + * function that can be used to compare references when sorting arrays. + */ +BibStrategy.prototype.getReferenceComparator = function() { + return function(lhs, rhs) { + var numCommonAuthors = Math.min(lhs.authors.length, rhs.authors.length); + for (var i = 0; i < numCommonAuthors; ++i) { + var lhsAuthor = lhs.authors[i]; + var rhsAuthor = rhs.authors[i]; + + var lastNameCmp = lhsAuthor.lastName.localeCompare(rhsAuthor.lastName); + if (lastNameCmp != 0) { + return lastNameCmp; + } + + var firstNameCmp = lhsAuthor.firstName.localeCompare(rhsAuthor.firstName); + if (firstNameCmp != 0) { + return firstNameCmp; + } + } + + var numAuthorsDiff = lhs.authors.length - rhs.authors.length; + if (numAuthorsDiff != 0) { + return numAuthorsDiff; + } + + return lhs.title.localeCompare(rhs.title); + }; +}; + + +// MLA format implementation logic: + + + +/** + * Constructs a new bibliography strategy for formatting citations in the Modern + * Language Association (MLA) format. + * @constructor + * @extends {BibStrategy} + */ +var MlaBibStrategy = function() { + BibStrategy.call(this); +}; +inherits(MlaBibStrategy, BibStrategy); + + +/** @override */ +MlaBibStrategy.prototype.getName = function() { + return 'mla'; +}; + + +/** @override */ +MlaBibStrategy.prototype.getBibliographyEntry = function(reference) { + var tokens = []; + + var firstAuthor = reference.authors[0]; + tokens.push({ + text: firstAuthor.lastName + ', ' + firstAuthor.firstName, + publicationTitle: false + }); + + if (reference.authors.length > 3) { + tokens.push({text: ', et al', publicationTitle: false}); + } else { + for (var i = 1; i < reference.authors.length; ++i) { + var author = reference.authors[i]; + tokens.push({ + text: ((i == reference.authors.length - 1) ? ', and ' : ', ') + + author.firstName + ' ' + author.lastName, + publicationTitle: false + }); + } + } + tokens.push({text: '. ', publicationTitle: false}); + + switch (reference.kind) { + case DataStore.ReferenceKind.ARTICLE: + tokens.push({ + text: '"' + reference.title + '." ', + publicationTitle: false + }); + tokens.push({text: reference.publication, publicationTitle: true}); + if (reference.journalVolume && reference.journalIssue) { + tokens.push({ + text: ' ' + reference.journalVolume + '.' + reference.journalIssue, + publicationTitle: false + }); + } else if (reference.journalIssue) { + tokens.push({text: reference.journalIssue, publicationTitle: false}); + } + tokens.push({ + text: ' (' + reference.publicationYear + '). ' + + reference.startPage + '\u2013' /* en dash */ + + reference.endPage + '.', + publicationTitle: false + }); + break; + + case DataStore.ReferenceKind.BOOK: + tokens.push({text: reference.title, publicationTitle: true}); + tokens.push({text: '.', publicationTitle: false}); + if (reference.edition) { + tokens.push({ + text: ' ' + reference.edition + ' ed.', + publicationTitle: false + }); + } + if (reference.volume) { + tokens.push({ + text: ' Vol. ' + reference.volume + '.', + publicationTitle: false + }); + } + tokens.push({ + text: ' ' + reference.publisherCity + ': ' + reference.publisher + + ', ' + reference.publicationYear + '.', + publicationTitle: false + }); + break; + } + + return tokens; +}; + + +/** @override */ +MlaBibStrategy.prototype.getReferenceForBibliographyEntry = function(tokens) { + // Note: If this were a real app instead of just a simple demo, we'd do this + // using a full-fledged parser rather than all this ugly regex code. + var reference = {}; + + if (tokens.length != 3 || tokens[0].publicationTitle || + !tokens[1].publicationTitle || tokens[2].publicationTitle) { + return null; + } + + // The first token contains the author information. For books, the title will + // be formatted in italics (and hence in the second token); however, for + // articles, the first token will also contain the non-italic article title. + var initialFields = tokens[0].text.match( + /([^,.]+), ([^,.]+)((?:, [^,.]+)*?)(?:, et al)?\. (.*)/); + if (!initialFields) { + return null; + } + + // The first author's name is special: The first last name comes first. :P + reference.authors = + [{lastName: initialFields[1], firstName: initialFields[2]}]; + + // Remaining authors names' are listed with the first name coming first. + var remainingAuthorRegExp = RegExp(', (?:and )?([^,.]+) ([^,.]+)', 'g'); + var remainingAuthorFields; + while (remainingAuthorFields = remainingAuthorRegExp.exec(initialFields[3])) { + reference.authors.push({ + lastName: remainingAuthorFields[2], + firstName: remainingAuthorFields[1] + }); + } + + // Article have non-italic titles immediately following the list of authors. + var articleTitle = initialFields[4].match(/"([^.]+)\."/); + if (articleTitle) { + reference.kind = DataStore.ReferenceKind.ARTICLE; + reference.title = articleTitle[1]; + } else { + reference.kind = DataStore.ReferenceKind.BOOK; + } + + // The second token contains either the journal name or the book name, and the + // third token contains all remaining information. + if (reference.kind == DataStore.ReferenceKind.ARTICLE) { + reference.publication = tokens[1].text; + + var remainingFields = tokens[2].text.match( + / (?:(\d+)\.)?(\d+)? \((\d+)\)\. (\d+)(?:\u2013|-)(\d+)/); + if (!remainingFields) { + return null; + } + + // If present, parse out journal volume and issue. + if (remainingFields[1]) { + reference.journalVolume = remainingFields[1]; + } + if (remainingFields[2]) { + reference.journalIssue = remainingFields[2]; + } + + // Parse out the article's publication year and page numbers. + reference.publicationYear = Number(remainingFields[3]); + reference.startPage = Number(remainingFields[4]); + reference.endPage = Number(remainingFields[5]); + } else if (reference.kind == DataStore.ReferenceKind.BOOK) { + reference.title = tokens[1].text; + + var remainingFields = tokens[2].text.match( + /.(?: ([^ ]+) ed\.)?(?: Vol\. ([^ ]+).)? ([^:]+): ([^,]+), ([^.]+)\./); + if (!remainingFields) { + return null; + } + + // If present, parse out the book's edition and volume. + if (remainingFields[1]) { + reference.edition = remainingFields[1]; + } + if (remainingFields[2]) { + reference.volume = remainingFields[2]; + } + + // Parse out the book's city of publication, publisher, and publication + // year. + reference.publisherCity = remainingFields[3]; + reference.publisher = remainingFields[4]; + reference.publicationYear = Number(remainingFields[5]); + } + + return reference; +}; + + +/** @override */ +MlaBibStrategy.prototype.getInlineCitationText = function(reference, startPage, + endPage, firstMention, abbreviateCitation) { + var authors = reference.authors; + + var authorPrefix = ''; + if (!abbreviateCitation) { + authorPrefix = authors[0].lastName; + switch (authors.length) { + case 1: + break; + case 2: + authorPrefix += ' and ' + authors[1].lastName; + break; + case 3: + authorPrefix += ', ' + authors[1].lastName + ', and ' + + authors[2].lastName; + break; + default: + authorPrefix += ' et al.'; + } + authorPrefix += ' '; + } + + return ['(', authorPrefix, startPage, '\u2013', endPage, ')'].join(''); +}; + + +/** @override */ +MlaBibStrategy.prototype.getBibliographyTitleText = function() { + return 'Works Cited'; +}; + + +// APA format implementation logic: + + + +/** + * Constructs a new bibliography strategy for formatting citations in the + * American Psychological Association (APA) format. + * @constructor + * @extends {BibStrategy} + */ +var ApaBibStrategy = function() { + BibStrategy.call(this); +}; +inherits(ApaBibStrategy, BibStrategy); + + +/** @override */ +ApaBibStrategy.prototype.getName = function() { + return 'apa'; +}; + + +/** @override */ +ApaBibStrategy.prototype.getBibliographyEntry = function(reference) { + var tokens = []; + + var firstAuthor = reference.authors[0]; + var middleAuthors; + var lastAuthor; + if (reference.authors.length == 1) { + middleAuthors = []; + lastAuthor = null; + } else if (reference.authors.length > 1 && reference.authors.length < 8) { + middleAuthors = reference.authors.slice(1, reference.authors.length - 1); + lastAuthor = reference.authors[reference.authors.length - 1]; + } else { + middleAuthors = reference.authors.slice(1, 6); + lastAuthor = reference.authors[reference.authors.length - 1]; + } + + tokens.push({ + text: firstAuthor.lastName + ', ' + firstAuthor.firstName.charAt(0) + '.', + publicationTitle: false + }); + for (var i = 0; i < middleAuthors.length; ++i) { + var author = middleAuthors[i]; + tokens.push({ + text: ', ' + author.lastName + ', ' + author.firstName.charAt(0) + '.', + publicationTitle: false + }); + } + if (lastAuthor) { + if (reference.authors.length < 8) { + tokens.push({ + text: ', & ' + lastAuthor.lastName + ', ' + + lastAuthor.firstName.charAt(0) + '.', + publicationTitle: false + }); + } else { + tokens.push({ + text: ', . . . ' + lastAuthor.lastName + ', ' + + lastAuthor.firstName.charAt(0) + '.', + publicationTitle: false + }); + } + } + + tokens.push({ + text: ' (' + reference.publicationYear + ').', + publicationTitle: false + }); + + switch (reference.kind) { + case DataStore.ReferenceKind.ARTICLE: + tokens.push({ + text: ' ' + reference.title + '. ', + publicationTitle: false + }); + tokens.push({text: reference.publication, publicationTitle: true}); + if (reference.journalVolume || reference.journalIssue) { + tokens.push({text: ', ', publicationTitle: false}); + } + if (reference.journalVolume) { + tokens.push({text: reference.journalVolume, publicationTitle: true}); + } + if (reference.journalIssue) { + tokens.push({ + text: '(' + reference.journalIssue + ')', + publicationTitle: false + }); + } + tokens.push({ + text: ', ' + reference.startPage + '\u2013' /* en dash */ + + reference.endPage + '.', + publicationTitle: false + }); + break; + + case DataStore.ReferenceKind.BOOK: + tokens.push({text: ' ', publicationTitle: false}); + tokens.push({text: reference.title, publicationTitle: true}); + var additionalBookInfo = []; + if (reference.edition) { + additionalBookInfo.push(reference.edition + ' ed.'); + } + if (reference.edition && reference.volume) { + additionalBookInfo.push(' '); + } + if (reference.volume) { + additionalBookInfo.push('Vol. ' + reference.volume); + } + if (additionalBookInfo.length) { + tokens.push({ + text: ' (' + additionalBookInfo.join('') + ')', + publicationTitle: false + }); + } + tokens.push({text: '.', publicationTitle: false}); + tokens.push({ + text: ' ' + reference.publisherCity + ': ' + reference.publisher + '.', + publicationTitle: false + }); + break; + } + + return tokens; +}; + + +/** @override */ +ApaBibStrategy.prototype.getReferenceForBibliographyEntry = function(tokens) { + // Note: If this were a real app instead of just a simple demo, we'd do this + // using a full-fledged parser rather than all this ugly regex code. + var reference = {}; + + if (tokens.length != 3 && tokens.length != 5 || + tokens[0].publicationTitle || + !tokens[1].publicationTitle || + tokens[2].publicationTitle || + tokens.length == 5 && !tokens[3].publicationTitle || + tokens.length == 5 && tokens[4].publicationTitle) { + return null; + } + + // The first token contains information of the reference's author(s) and + // publication year. For articles, the first token will also contain the + // non-italic article title. + var initialFields = tokens[0].text.match( + '([^,]+), ([^,.]+)\\.((?:, (?:& |\\. \\. \\. |)[^,]+, [^,.]+\\.)*) ' + + '\\((\\d+)\\)\\.(?: ([^.]+)\\.)?'); + if (!initialFields) { + return null; + } + + // Extract the first author's name. Unfortunately APA format only uses + // initials for first names, so we can't actually import the full name. + reference.authors = + [{lastName: initialFields[1], firstName: initialFields[2]}]; + + // Extract the names of any authors after the first. Note that an APA + // bibliography entry will list no more than seven authors, so we might not + // have enough information to import everything. + var remainingAuthorRegExp = RegExp(', (?:& |. . . |)([^,]+), ([^,.]+)\.', + 'g'); + var remainingAuthorFields; + while (remainingAuthorFields = remainingAuthorRegExp.exec(initialFields[3])) { + reference.authors.push({ + lastName: remainingAuthorFields[1], + firstName: remainingAuthorFields[2] + }); + } + + // Extract the publication year from the penultimate field in the first token. + reference.publicationYear = Number(initialFields[4]); + + // If the final field from the first token is filled, this reference + // represents an article rather than a book, and the final field contains the + // article's title. + var articleTitle = initialFields[5]; + if (articleTitle) { + reference.kind = DataStore.ReferenceKind.ARTICLE; + reference.title = articleTitle; + } else { + reference.kind = DataStore.ReferenceKind.BOOK; + } + + // The second token contains either a journal name or a book name, while the + // third (and fourth, if present) tokens contain additional information. + if (reference.kind == DataStore.ReferenceKind.ARTICLE) { + reference.publication = tokens[1].text; + + // If there's a second italicized token, it contains the volume number. + if (tokens.length == 5) { + reference.journalVolume = tokens[3].text; + } + + // Now, pull out the issue number (if present) and page numbers. + var remainingFields = tokens[tokens.length - 1].text.match( + /(?:\((\d+)\), )?(\d+)(?:\u2013|-)(\d+)/); + if (!remainingFields) { + return null; + } + + if (remainingFields[1]) { + reference.journalIssue = remainingFields[1]; + } + + reference.startPage = Number(remainingFields[2]); + reference.endPage = Number(remainingFields[3]); + } else if (reference.kind == DataStore.ReferenceKind.BOOK) { + if (tokens.length != 3) { + return null; + } + + reference.title = tokens[1].text; + + var remainingFields = tokens[2].text.match( + /(?: \((?:([^ ]+) ed\.)? ?(?:Vol\. ([^ ]+))?\))?\. ([^:]+): ([^.]+)\./); + if (!remainingFields) { + return null; + } + + // If present, parse out the book's edition and volume. + if (remainingFields[1]) { + reference.edition = remainingFields[1]; + } + if (remainingFields[2]) { + reference.volume = remainingFields[2]; + } + + // Parse out the book's city of publication and publisher. + reference.publisherCity = remainingFields[3]; + reference.publisher = remainingFields[4]; + } + + return reference; +}; + + +/** @override */ +ApaBibStrategy.prototype.getInlineCitationText = function(reference, startPage, + endPage, firstMention, abbreviateCitation) { + var authors = reference.authors; + + var authorPrefix = ''; + if (!abbreviateCitation) { + authorPrefix = authors[0].lastName; + if (authors.length == 2) { + authorPrefix += ' & ' + authors[1].lastName; + } else if (authors.length > 2 && authors.length < 5 && firstMention) { + for (var i = 1; i < authors.length - 1; ++i) { + authorPrefix += ', ' + authors[i].lastName; + } + authorPrefix += ', & ' + authors[authors.length - 1].lastName; + } else if (authors.length > 5 || authors.length > 1 && !firstMention) { + authorPrefix += ' et al.'; + } + authorPrefix += ', '; + } + + var pageSuffix = (startPage != endPage) ? + ', pp. ' + startPage + '\u2013' /* en dash */ + endPage : + ', p. ' + startPage; + + return ['(', authorPrefix, reference.publicationYear, pageSuffix, ')']. + join(''); +}; + + +/** @override */ +ApaBibStrategy.prototype.getBibliographyTitleText = function() { + return 'References'; +}; + + +/** + * The bibliography strategy constructors for each supported citation format. + * @type {!Object.} + * @const + */ +BibStrategy.Implementation = { + 'mla': { + description: 'Modern Language Association', + ctor: MlaBibStrategy + }, + 'apa': { + description: 'American Psychological Association', + ctor: ApaBibStrategy + } +}; + + +/** + * Closure-style type definition for a bibliography entry token. This is an + * abstract version of a formatted run of text (right now, either italicized as + * a publication title or in roman text otherwise) that can either be written + * into the active document or converted to HTML for rendering the sidebar. + * @typedef {{text: string, publicationTitle: boolean}} + */ +BibStrategy.BibEntryToken; diff --git a/bibstro/citationdialog.html b/bibstro/citationdialog.html new file mode 100644 index 000000000..1c70b0437 --- /dev/null +++ b/bibstro/citationdialog.html @@ -0,0 +1,115 @@ + +Google Apps Script and Google Docs demo: Bibstro + + + + +
+
+ Insert an inline citation for an existing source + +
+ +
+
+
    + +
  • + +
  • + +
+
+ +
+
+ +
+ +
+ + – + +
+
+ +
+ +
+ + +
+
+
+ +
+ + +
+
+ + diff --git a/bibstro/citationdialog_js.html b/bibstro/citationdialog_js.html new file mode 100644 index 000000000..11d2fc708 --- /dev/null +++ b/bibstro/citationdialog_js.html @@ -0,0 +1,60 @@ + + + + + diff --git a/bibstro/common.gs b/bibstro/common.gs new file mode 100644 index 000000000..7795b1aa2 --- /dev/null +++ b/bibstro/common.gs @@ -0,0 +1,91 @@ +// Copyright 2013 Google Inc. All Rights Reserved. +// +// 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. + +/** + * @fileOverview Google Apps Script and Google Docs demo: Bibstro. Utility + * functions. + * @author Jonathan Rascher + * @author Saurabh Gupta + */ + + +/** + * This is a slightly simplified version of Closure's {@code goog.inherits} + * function. It sets up the prototype of the first constructor so that the first + * class acts as a subclass of the second class. + * @param {!Function} childCtor The subclass's constructor function. + * @param {!Function} parentCtor The superclass's constructor function. + */ +function inherits(childCtor, parentCtor) { + /** @constructor */ function childPrototypeCtor() {} + childPrototypeCtor.prototype = parentCtor.prototype; + childCtor.prototype = new childPrototypeCtor(); + childCtor.prototype.constructor = parentCtor; // Make instanceof work. +} + + +/** + * Takes an object like {@code {'foo[0]': 'a', 'foo[1]': 'b', 'foo[2]': 'c'}} + * and turns it into an array like {@code ['a', 'b', 'c']}. + * @param {!Object} obj The object containing the array key/value pairs (and + * possibly other properties as well. + * @param {string} prefix The portion of the array key names before the square + * brackets (e.g., {@code foo} in the example above). + * @return {!Array} The array extracted from the original object. + */ +function extractArrayFields(obj, prefix) { + var arrayIndicies = Object.keys(obj).map(function(key) { + if (key.indexOf(prefix) != 0) { + return null; + } + var arrayIndexStr = key.substring(prefix.length); + var matchResults = /^\[([0-9]+)\]$/.exec(arrayIndexStr); + return matchResults ? Number(matchResults[1]) : null; + }).filter(function(index) { + return index != null; + }).sort(); + + var ret = []; + for (var i = 0; i < arrayIndicies.length; ++i) { + ret[i] = obj[prefix + '[' + i + ']']; + } + return ret; +} + + +/** + * Escapes HTML special characters. + * @param {string} unescapedStr A string that might contain unescaped HTML tags + * or entities. + * @return {string} The same string with characters having special meaning in + * HTML properly escaped. + */ +function escapeHtml(unescapedStr) { + var template = HtmlService.createTemplate(''); + template.unescaped = unescapedStr; + return template.evaluate().getContent(); +} + + +/** + * Escapes RegExp special characters. + * @param {string} unescapedStr A string that might contain unescaped regular + * expression metacharacters. + * @return {string} The same string with characters having special meaning in + * JavaScript regular expressions properly escaped. + * @see StackOverflow + */ +function escapeRegExp(unescapedStr) { + return unescapedStr.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); +} diff --git a/bibstro/configdialog.html b/bibstro/configdialog.html new file mode 100644 index 000000000..66cb93493 --- /dev/null +++ b/bibstro/configdialog.html @@ -0,0 +1,86 @@ + +Google Apps Script and Google Docs demo: Bibstro + + + + +
+
+ Configure bibliography settings + +

+ + Before you begin entering sources and inserting citations, please take a minute to configure + the application to your liking. Thank you kindly! + + Change your citation format or other application settings here. None of your sources will be + lost, so feel free to play around. + +

+ + +
+ +
+ +
+
+ + +
+ +
+ + Highly recommended, or else existing sources may be deleted. +
+
+ +
+ +
+ + +
+
+ + diff --git a/bibstro/configdialog_js.html b/bibstro/configdialog_js.html new file mode 100644 index 000000000..00bde87f4 --- /dev/null +++ b/bibstro/configdialog_js.html @@ -0,0 +1,40 @@ + + + + diff --git a/bibstro/controller.gs b/bibstro/controller.gs new file mode 100644 index 000000000..bf1e6ec3c --- /dev/null +++ b/bibstro/controller.gs @@ -0,0 +1,467 @@ +// Copyright 2013 Google Inc. All Rights Reserved. +// +// 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. + +/** + * @fileOverview Google Apps Script and Google Docs demo: Bibstro. Controller + * class. User interaction event handlers call into this class to perform any + * real work. The controller is responsible for showing UI, as well as managing + * interactions with the {@code ScriptDb}-based data store and the + * {@code DocumentApp}-based document model. + * @author Jonathan Rascher + * @author Saurabh Gupta + */ + + + +/** + * Constructs a new instance of the controller class. Though this is not + * enforced in code, the controller should be treated as a singleton; i.e., at + * most once instance should be created by server-side script execution. + * @constructor + */ +var Controller = function() { + /** + * A caching wrapper around the {@code ScriptDb} data store. + * @type {!DataStore} + * @private + */ + this.dataStore_ = new DataStore(); + + /** + * A simple, abstract document model based on the {@code DocumentApp} service. + * @type {!DocumentModel} + * @private + */ + this.documentModel_ = new DocumentModel(DocumentApp.getActiveDocument()); + + var config = this.dataStore_.getConfig(); +}; + + +/** + * The desired width in pixels of {@code citationdialog.html}. + * @type {number} + * @const + * @private + */ +Controller.CITATION_DIALOG_WIDTH_ = 600; + + +/** + * The desired height in pixels of {@code citationdialog.html}. + * @type {number} + * @const + * @private + */ +Controller.CITATION_DIALOG_HEIGHT_ = 575; + + +/** + * The desired width in pixels of {@code configdialog.html}. + * @type {number} + * @const + * @private + */ +Controller.CONFIG_DIALOG_WIDTH_ = 625; + + +/** + * The desired height in pixels of {@code configdialog.html}. + * @type {number} + * @const + * @private + */ +Controller.CONFIG_DIALOG_HEIGHT_ = 525; + + +/** + * The desired width in pixels of {@code referencedialog.html}. + * @type {number} + * @const + * @private + */ +Controller.REFERENCE_DIALOG_WIDTH_ = 700; + + +/** + * The desired height in pixels of {@code referencedialog.html}. + * @type {number} + * @const + * @private + */ +Controller.REFERENCE_DIALOG_HEIGHT = 650; + + +/** + * The desired width in pixels of {@code managereferencessidebar.html}. + * @type {number} + * @const + * @private + */ +Controller.MANAGE_REFERENCES_SIDEBAR_WIDTH_ = 450; + + +/** + * Email address format strings for various cell phone carriers' email-to-SMS + * gateways. + * @enum {string} + * @private + */ +Controller.PhoneCarriersToEmailAddressFormats_ = { + 'att': '%s@txt.att.net', + 'sprint': '%s@messaging.sprintpcs.com', + 'tmobile': '%s@tmomail.net', + 'verizon': '%s@vtext.com' +}; + + +/** + * On initial installation, this method shows a configuration dialog prompting + * the user to chose the citation format of their desire. If configuration + * already exists, this method does nothing. + * @return {boolean} True if the initial setup dialog was shown and false if + * initial setup has already been performed for this document. + */ +Controller.prototype.showInitialSetupDialog = function() { + if (!this.dataStore_.getConfig()) { + // If there's no configuration saved in the data store, this must be the + // first time the user has opened the current document with the extension + // installed. Show a configuration dialog allowing the user to choose a + // citation format. + this.showConfigDialog(true /* opt_initialSetup */); + return true; // Don't proceed with initialization until configured. + } + return false; // If we already have a config, proceed with initialization. +}; + + +/** + * Displays {@code configdialog.html}, allowing the user to add or edit + * Bibstro's configuration for this document. + * @param {boolean=} opt_initialSetup Whether this is the app's initial setup + * step (in which case no configuration currently exists in + * {@code ScriptDb}. + */ +Controller.prototype.showConfigDialog = function(opt_initialSetup) { + var config = this.dataStore_.getConfig(); + var args = {'initialSetup': !!opt_initialSetup, 'bibStrategies': []}; + + // Populate human-readable bibliography strategy names for the various + // citation formats Bibstro supports (e.g., MLA and APA). These names are + // passed into the dialog view. + for (var bibStrategyName in BibStrategy.Implementation) { + var bibStrategy = { + 'name': bibStrategyName, + 'description': BibStrategy.Implementation[bibStrategyName].description + }; + if (config && config.bibStrategy == bibStrategyName) { + bibStrategy['selected'] = true; + } + args['bibStrategies'].push(bibStrategy); + } + + this.showDialog('configdialog.html', Controller.CONFIG_DIALOG_WIDTH_, + Controller.CONFIG_DIALOG_HEIGHT_, args); +}; + + +/** + * Displays {@code referencedialog.html}, allowing the user to insert or update + * a reference in the bibliography data store. + * @param {string=} opt_id If present, the {@code ScriptDb} identifier of the + * existing reference to be updated. If absent, an empty dialog shall be + * shown, allowing the user to insert a new reference instead. + */ +Controller.prototype.showReferenceDialog = function(opt_id) { + this.showDialog('referencedialog.html', Controller.REFERENCE_DIALOG_WIDTH_, + Controller.REFERENCE_DIALOG_HEIGHT, + opt_id ? this.dataStore_.loadReference(opt_id) : undefined); +}; + + +/** + * Displays {@code managereferencessidebar.html}, a live, formatted view of the + * bibliography data store that appears adjacent to the Google Docs document. + */ +Controller.prototype.showManageReferenceSidebar = function() { + this.showSidebar('managereferencessidebar.html', + 'Manage Bibliography Sources', + Controller.MANAGE_REFERENCES_SIDEBAR_WIDTH_); +}; + + +/** + * Attempts to import reference data from an existing document bibliography into + * the document's data store. The preexisting bibliography must be formatted + * according to the currently selected citation format for this to have any + * change of succeeding. + */ +Controller.prototype.importReferencesFromDocument = function() { + var references = this.documentModel_.extractReferences(this.getBibStrategy()); + for (var i = 0; i < references.length; ++i) { + this.dataStore_.insertOrUpdateReference(references[i]); + } +}; + + +/** + * Displays {@code citationdialog.html}, allowing the user to insert an inline + * citation at the current cursor location. If there are no references in the + * data store, the user will be prompted to add a reference instead. + */ +Controller.prototype.showCitationDialog = function() { + var referenceData = this.loadReferenceData(); + + // Before showing the "insert citation" dialog, make sure the user has + // actually put at least one reference into the data store. If no references + // can be found, ask the user if they'd like to add a reference instead. + if (!referenceData['references'].length) { + var confirmation = DocumentApp.getUi().alert( + 'Google Apps Script and Google Docs demo: Bibstro', + 'This document does not currently have any sources to cite. Would you' + + 'like to add a source now?', + DocumentApp.getUi().ButtonSet.YES_NO); + if (confirmation == DocumentApp.getUi().Button.YES) { + this.showReferenceDialog(); + } + return; + } + + this.showDialog('citationdialog.html', + Controller.CITATION_DIALOG_WIDTH_, Controller.CITATION_DIALOG_HEIGHT_, + referenceData); +}; + + +/** + * Returns the document's current bibliography strategy. It's a citation format + * (e.g., MLA or APA), essentially. + * @return {BibStrategy} The document's current bibliography strategy, or null + * if initial setup hasn't yet been performed. + */ +Controller.prototype.getBibStrategy = function() { + var config = this.dataStore_.getConfig(); + return config ? + new BibStrategy.Implementation[this.dataStore_.getConfig().bibStrategy]. + ctor() : + null; +}; + + +/** + * Loads the full list of references from the data store, producing properly + * formatted HTML bibliography entries for each. + * @return {{bibStrategy: string, + * references: !Array.<{id: string, html: string}>, + * highlights: !Array.}} + */ +Controller.prototype.loadReferenceData = function() { + var references = this.dataStore_.loadReferences(). + sort(this.getBibStrategy().getReferenceComparator()); + + var referenceDataList = []; + for (var i = 0; i < references.length; ++i) { + var reference = references[i]; + + // Turn the structured reference data into a chunk of formatted HTML to send + // down to the client. + var referenceData = {}; + referenceData['id'] = reference.id; + referenceData['html'] = this.getBibliographyEntryHtml(reference); + referenceDataList.push(referenceData); + } + + return { + 'references': referenceDataList, + 'highlights': this.dataStore_.getHighlights() + }; +}; + + +/** + * Formats a reference model object from the data store as HTML according to the + * current bibliography strategy (citation format). + * @param {!DataStore.Reference} reference The reference object to be rendered. + * @return {string} HTML that can be rendered directly into an + * {@link HtmlTemplate} view. + */ +Controller.prototype.getBibliographyEntryHtml = function(reference) { + return this.getBibStrategy().getBibliographyEntry(reference). + map(function(bibEntryToken) { + return bibEntryToken.publicationTitle ? + '' + escapeHtml(bibEntryToken.text) + '' : + escapeHtml(bibEntryToken.text); + }).join(''); +}; + + +/** + * Highlights or unhighlights a reference's citations in the document and marks + * the reference as highlighted or not in the {@code ScriptDb} data store. + * @param {string} id The {@code ScriptDb} identifier of the reference to + * highlight or unhighlight. + * @param {boolean=} opt_unhighlight Whether to clear any existing highlights + * for this reference instead of setting new highlights. + */ +Controller.prototype.highlightReference = function(id, opt_unhighlight) { + this.dataStore_.highlightReference(id, opt_unhighlight); + this.documentModel_.highlightMatchingCitations(this.getBibStrategy(), + this.dataStore_.loadReferences(), this.dataStore_.loadReference(id), + opt_unhighlight); +}; + + +/** + * Deletes a reference from the {@code ScriptDb} data store. + * @param {string} id The {@code ScriptDb} identifier of the reference to + * delete. + */ +Controller.prototype.deleteReference = function(id) { + this.dataStore_.deleteReference(id); + this.refreshLiveBibliography(); +}; + + +/** + * Inserts of updates a reference in the {@code ScriptDb} data store. + * @param {!DataStore.Reference} reference The reference model object to update + * (if an {@code id} property is present) or insert (if no ID is present). + */ +Controller.prototype.saveReference = function(reference) { + this.dataStore_.insertOrUpdateReference(reference); + this.refreshLiveBibliography(); +}; + + +/** + * Saves a configuration object containing app settings (e.g., the current + * citation format) back to the data store. + * @param {!DataStore.Config} config The configuration settings to save. + */ +Controller.prototype.saveConfig = function(config) { + this.dataStore_.setConfig(config); +}; + + +/** + * Inserts an inline citation at the document cursor location using the + * currently selected citation format. + * @param {string} referenceId The {@code ScriptDb} identifier of the reference + * being cited. + * @param {number} startPage The first page included in this citation. + * @param {number} endPage The last page included in this citation. + * @param {boolean} firstMention Whether or not this reference has been cited + * earlier in the document. For certain citation formats (e.g., APA), the + * first citation associated with a given reference must contain information + * that should be omitted in later citations. + * @param {boolean} abbreviateCitation If true, the inserted inline citation + * will be abbreviated somehow. Exactly how depends on the specific citation + * format selected; however, in most formats the author(s) will be omitted + * and only the page number(s) will be included. + */ +Controller.prototype.insertCitation = function(referenceId, startPage, endPage, + firstMention, abbreviateCitation) { + this.documentModel_.insertCitationAtCursor(this.getBibStrategy(), + this.dataStore_.loadReference(referenceId), startPage, endPage, + firstMention, abbreviateCitation); +}; + + +/** + * Refreshes the contents of the active document's bibliography (e.g., "Works + * Cited" list for MLA), appending a new bibliography if one doesn't already + * exist. + */ +Controller.prototype.appendLiveBibliography = function() { + this.documentModel_.appendOrUpdateBibliography(this.getBibStrategy(), + this.dataStore_.loadReferences()); +}; + + +/** + * Refreshes the contents of the active document's bibliography (e.g., "Works + * Cited" list for MLA). If no live bibliography currently exists, then no + * action will be taken. + * @param {!BibStrategy=} opt_prevBibStrategy If set, the bibliography strategy + * that was active before a change in citation format. This is used to find + * the document's live bibliography using the previous citation format so + * that it can be updated to the new format. + */ +Controller.prototype.refreshLiveBibliography = function(opt_prevBibStrategy) { + this.documentModel_.appendOrUpdateBibliography(this.getBibStrategy(), + this.dataStore_.loadReferences(), true /* opt_updateOnly */, + opt_prevBibStrategy); +} + + +/** + * Renders an HTML file view in a dialog above the Google Docs document. + * @param {string} fileName The file name of the template to evaluate. + * @param {number} width The desired dialog width in pixels. + * @param {number} height The desired dialog height in pixels. + * @param {!Object=} opt_args If defined, an object whose properties shall be + * copied to the {@code HtmlTemplate} object before the template is + * evaluated. + */ +Controller.prototype.showDialog = function(fileName, width, height, opt_args) { + DocumentApp.getUi().showDialog( + this.evalTemplate(fileName, undefined, opt_args). + setWidth(width). + setHeight(height)); +}; + + +/** + * Renders an HTML file view in the Google Docs sidebar. + * @param {string} fileName The file name of the template to evaluate. + * @param {string} title The title of the page, dialog, or sidebar in which the + * evaluated template will eventually be displayed. + * @param {number} width The desired sidebar width in pixels. + * @param {!Object=} opt_args If defined, an object whose properties shall be + * copied to the {@code HtmlTemplate} object before the template is + * evaluated. + */ +Controller.prototype.showSidebar = function(fileName, title, width, opt_args) { + DocumentApp.getUi().showSidebar(this.evalTemplate(fileName, title, opt_args). + setWidth(width)); +}; + + +/** + * Evaluates an {@code HtmlService} template using the specified page title and + * template arguments. + * @param {string} fileName The file name of the template to evaluate. + * @param {string=} opt_title The title of the page, dialog, or sidebar in which + * the evaluated template will eventually be displayed. + * @param {!Object=} opt_args If defined, an object whose properties shall be + * copied to the {@code HtmlTemplate} object before the template is + * evaluated. + * @return {!HtmlService.HtmlOutput} + */ +Controller.prototype.evalTemplate = function(fileName, opt_title, opt_args) { + var template = HtmlService.createTemplateFromFile(fileName); + var args = opt_args || {}; + for (var key in args) { + if (args.hasOwnProperty(key)) { + template[key] = args[key]; + } + } + + var output = template.evaluate(); + output.setSandboxMode(HtmlService.SandboxMode.NATIVE); // Enable Caja ES5. + if (opt_title != undefined) { + output.setTitle(opt_title); + } + return output; +}; diff --git a/bibstro/datastore.gs b/bibstro/datastore.gs new file mode 100644 index 000000000..2f4f183ab --- /dev/null +++ b/bibstro/datastore.gs @@ -0,0 +1,533 @@ +// Copyright 2013 Google Inc. All Rights Reserved. +// +// 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. + +/** + * @fileOverview Google Apps Script and Google Docs demo: Bibstro. Data model + * definitions and caching data store built on top of {@code ScriptDb}. + * @author Jonathan Rascher + * @author Saurabh Gupta + */ + + + +/** + * Constructs a new caching data store object. The data store is backed by + * {@code ScriptDb}, a simple NoSQL database that holds JSON-compatible objects. + * Since all {@code ScriptDb} records end up in the same namespace, we tag each + * record with a {@code type} property determining whether the record represents + * a configuration entry or a bibliography reference. + *

+ * Additionally, since {@code ScriptDb} has quotas on how often a given script + * can read from the database (i.e., a limit of queries per second or QPS), we + * use a simple write-through cache based on Apps Script's {@code CacheService}. + * @constructor + */ +var DataStore = function() { + /** + * The {@code ScriptDb} database for the active document. + * @type {!ScriptDb.ScriptDbInstance} + * @private + */ + this.scriptDb_ = ScriptDb.getMyDb(); + + + /** + * A simple memcache-style instance used as a write-through cache on top of + * {@code ScriptDb}. We use the public cache since bibliography is scoped to + * the document and is not user-specific in any way. + * @type {!CacheService.Cache} + * @private + */ + this.cache_ = CacheService.getPublicCache(); + + + /** + * The {@code ScriptDb} record representing Bibstro's current configuration, + * or null if Bibstro has not yet been configured. + * @type {ScriptDb.ScriptDbMap} + * @private + */ + this.configRecord_ = this.queryRecord_({'type': DataStore.Type_.CONFIG}); +}; + + +/** + * Key of cache record containing a JSON-formatted array of the {@code ScriptDb} + * identifiers of all references currently stored in the data store. + * @type {string} + */ +DataStore.REFERENCE_LIST_CACHE_KEY = 'refList'; + + +/** + * Key of cache record containing a JSON-formatted array of the {@code ScriptDb} + * identifiers of all references whose associated inline citations are currently + * highlighted in the document. + * @type {string} + */ +DataStore.HIGHLIGHT_LIST_CACHE_KEY = 'hglList'; + + +/** + * Unique prefix for the keys of cache records containing JSON-formatted + * reference data. + * @type {string} + */ +DataStore.REFERENCE_CACHE_KEY_PREFIX = 'ref_'; + + +/** + * Closure-style type definition documenting the model object representing + * Bibstro's configuration. + * @typedef {{bibStrategy: string}} + */ +DataStore.Config; + + +/** + * Closure-style type definition documenting the model object representing a + * single reference in Bibstro's bibliography backend. Note that this represents + * the union of properties supported by all kinds of references. Not all will + * apply under all circumstances; e.g., {@code publication} is valid for journal + * and newspaper, articles but not for books. + *

+ * The {@code kind} property determines what sort of reference a given model + * object represents. + * @typedef {{id: string|undefined, + * kind: DataStore.ReferenceKind, + * title: string, + * publicationYear: number, + * publication: string|undefined, + * journalVolume: string|undefined, + * journalIssue: string|undefined, + * startPage: number|undefined, + * endPage: number|undefined, + * edition: string|undefined, + * volume: string|undefined, + * publisher: string|undefined, + * publisherCity: string|undefined}} + */ +DataStore.Reference; + + +/** + * A distinct kind of reference (book, journal article, etc.) currently + * supported by Bibstro. Since this is only a demo app, there are not many + * reference kinds supported. + * @enum {string} + */ +DataStore.ReferenceKind = { + ARTICLE: 'article', + BOOK: 'book' +}; + + +/** + * The various sorts of data that Bibstro stores in {@code ScriptDb}. + * @enum {string} + * @private + */ +DataStore.Type_ = { + CONFIG: 'cfg', + REFERENCE: 'ref', + HIGHLIGHT: 'hgl', +}; + + +/** + * Loads configuration settings from the data store. + * @return {DataStore.Config} The current app configuration, or null if the app + * hasn't yet been configured. + */ +DataStore.prototype.getConfig = function() { + if (!this.configRecord_) { + return null; + } + var config = {bibStrategy: this.configRecord_['bibStrategy']}; + return config; +}; + + +/** + * Saves a new or updated app configuration to the data store. + * @param {!DataStore.Config} The new configuration model object to write out. + */ +DataStore.prototype.setConfig = function(config) { + var newConfigRecord = this.configRecord_ || {}; + newConfigRecord['type'] = DataStore.Type_.CONFIG; + newConfigRecord['bibStrategy'] = config.bibStrategy; + this.configRecord_ = this.scriptDb_.save(newConfigRecord); +}; + + +/** + * Loads data on all references in the bibliography, trying the cache first and + * the database backend second. The references are not guaranteed to be returned + * in any particular order since the sort order could depend on the current + * bibliography strategy (citation format). + * @return {!Array.} The (possibly empty) list of + * references. + */ +DataStore.prototype.loadReferences = function() { + // First, try and read references from the cache. To make sure we don't miss + // out on some references (e.g., because their cache entries have been garbage + // collected), we also read in the full list of reference IDs from the cache. + // If the list of reference IDs is missing, or if we can't find data for a + // reference, then we give up and do a ScriptDb query instead. + var cachedReferenceIds = + JSON.parse(this.cache_.get(DataStore.REFERENCE_LIST_CACHE_KEY)); + if (cachedReferenceIds) { + var cachedReferences = []; + + for (var i = 0; i < cachedReferenceIds.length; ++i) { + var cachedReference = + JSON.parse(this.cache_.get(DataStore.REFERENCE_CACHE_KEY_PREFIX + + cachedReferenceIds[i])); + if (!cachedReference) { + break; + } + cachedReferences.push(cachedReference); + } + + if (cachedReferences.length == cachedReferenceIds.length) { + return cachedReferences; // Successfully loaded all data from the cache! + } + } + + // We aren't certain that we could load all data from the cache, so we play it + // safe and query ScriptDb instead. + var referenceRecords = + this.queryRecords_({'type': DataStore.Type_.REFERENCE}); + + // Convert raw ScriptDbMap records into reference objects that we can safely + // pass outside this class. Also, as we go, insert reference model objects + // into the cache so the next read will (mostly likely) have to hit ScriptDb. + var references = []; + var referenceIds = []; + for (var i = 0; i < referenceRecords.length; ++i) { + var referenceRecord = referenceRecords[i]; + var reference = this.createModelForReferenceRecord_(referenceRecord); + + references.push(reference); + referenceIds.push(reference.id); + + this.cache_.put(DataStore.REFERENCE_CACHE_KEY_PREFIX + reference.id, + JSON.stringify(reference)); + } + + this.cache_.put(DataStore.REFERENCE_LIST_CACHE_KEY, + JSON.stringify(referenceIds)); + + return references; +}; + + +/** + * Loads a single reference from the cache (if possible) or the database + * backend. + * @param {string} id The {@code ScriptDb} identifier of the reference in + * question. + * @return {DataStore.Reference} The reference requested, or null if no + * reference with the specified ID could be found. + */ +DataStore.prototype.loadReference = function(id) { + // First, try to read from the cache. The cache is a consistent storage layer, + // so cached data is guaranteed not to be stale; however, it's entirely + // possible that given record has been completely purged from the cache. + var cachedReference = + JSON.parse(this.cache_.get(DataStore.REFERENCE_CACHE_KEY_PREFIX + id)); + if (cachedReference) { + return cachedReference; + } + + // If we didn't find data for this reference in the cache, try and look up the + // record with the specified ID in ScriptDb, noting such a record might not + // exist. + var referenceRecord = this.scriptDb_.load(id); + return referenceRecord ? + this.createModelForReferenceRecord_(referenceRecord) : null; +}; + + +/** + * Deletes a single reference from the cache and the database backend. + * @param {string} id The {@code ScriptDb} identifier of the reference in + * question. + */ +DataStore.prototype.deleteReference = function(id) { + this.scriptDb_.remove(this.scriptDb_.load(id)); + this.cache_.remove(DataStore.REFERENCE_CACHE_KEY_PREFIX + id); + + // If the reference was highlighted, we need to remove the reference ID from + // the highlight list in the database and in the cache. + this.highlightReference(id, true /* opt_unhighlight */); + + // If a full read of all references has been cached, then we also need to + // remove the reference ID from the cache's list of known references. + var cachedReferenceList = + JSON.parse(this.cache_.get(DataStore.REFERENCE_LIST_CACHE_KEY)); + if (cachedReferenceList) { + var cachedReferenceListIndex = cachedReferenceList.indexOf(id); + if (cachedReferenceListIndex != -1) { + cachedReferenceList.splice(cachedReferenceListIndex, 1); + this.cache_.put(DataStore.REFERENCE_LIST_CACHE_KEY, + JSON.stringify(cachedReferenceList)); + } + } +}; + + +/** + * Determines what references in the bibliography ought to have their inline + * citations highlighted in the active Google Docs document. + * @return {!Array.} The {@code ScriptDb} identifiers of all currently + * highlighted references. + */ +DataStore.prototype.getHighlights = function() { + // First, try to read from the cache. + var cachedHighlightList = + JSON.parse(this.cache_.get(DataStore.HIGHLIGHT_LIST_CACHE_KEY)); + if (cachedHighlightList) { + return cachedHighlightList; + } + + // Otherwise, fall through to ScriptDb. + var highlightListRecord = + this.queryRecord_({'type': DataStore.Type_.HIGHLIGHT}); + return highlightListRecord ? highlightListRecord['ids'] : []; +}; + + +/** + * Marks a reference as highlighted or not in the {@code ScriptDb} data store. + * @param {string} id The {@code ScriptDb} identifier of the reference to + * highlight or unhighlight. + * @param {boolean=} opt_unhighlight Whether to clear the highlight mark instead + * of setting it. + */ +DataStore.prototype.highlightReference = function(id, opt_unhighlight) { + var highlightListRecord = + this.queryRecord_({'type': DataStore.Type_.HIGHLIGHT}) || + {'type': DataStore.Type_.HIGHLIGHT, 'ids': []}; + if (opt_unhighlight) { + var highlightListIndex = highlightListRecord['ids'].indexOf(id); + if (highlightListIndex != -1) { + highlightListRecord['ids'].splice(highlightListIndex, 1); + } + } else { + if (highlightListRecord['ids'].indexOf(id) == -1) { + highlightListRecord['ids'].push(id); + } + } + this.scriptDb_.save(highlightListRecord); + this.cache_.put(DataStore.HIGHLIGHT_LIST_CACHE_KEY, + JSON.stringify(highlightListRecord['ids'])); +}; + + +/** + * Inserts a new reference into the cache and database backend, or updates an + * existing reference. + * @param {!DataStore.Reference} reference The reference model object to be + * inserted (if the {@code id} property is undefined) or updated (if the + * {@code id} property is defined) in the data store. + * @return {string} The identifier of the inserted or updated data store record. + */ +DataStore.prototype.insertOrUpdateReference = function(reference) { + if (!this.validateReferenceModel_(reference)) { + throw new Error('Invalid reference model: ' + JSON.stringify(reference)); + } + + // For insert, simply create a new, empty record object. For update, retrieve + // the existing reference record so we can overwrite it. (ScriptDb doesn't + // allow replacing an object by ID for some reason.) + var referenceRecord = {}; + if (reference.id) { + referenceRecord = this.scriptDb_.load(reference.id); + if (!referenceRecord) { + throw new Error('Could not find reference in data store for update: ' + + reference.id); + } + } + + // Remove all properties from the record. This is required when updating + // existing records, as we may be changing the record type, and so some fields + // might not need to be set anymore. (For example, if we're changing the type + // from BOOK to JOURNAL, the 'publisherCity' field should no longer be set.) + for (var key in referenceRecord) { + if (referenceRecord.hasOwnProperty(key)) { + delete referenceRecord[key]; + } + } + + // Make sure we can look the record up by type later of. + referenceRecord['type'] = DataStore.Type_.REFERENCE; + + // Copy fields from the model object to the underlying ScriptDb record. At the + // moment, this only removes the ID, but it might perform fancier conversions + // in the future. + for (var key in reference) { + if (key != 'id' && reference[key] !== undefined && + reference.hasOwnProperty(key)) { + referenceRecord[key] = reference[key]; + } + } + + // We use CacheService as a write-through cache on top of ScriptDb for + // performance and quota reasons, so do two writes: one to the underlying data + // store and one to the cache. + var id = this.scriptDb_.save(referenceRecord).getId(); + + if (!reference.id) { + reference.id = id; + + // The cached reference list may not exist (either because it expired or + // simply because it hasn't been computed yet). In that case, we don't + // need to update the list. However, if the list does exist, we need to + // append the new reference ID. + var cachedReferenceList = + JSON.parse(this.cache_.get(DataStore.REFERENCE_LIST_CACHE_KEY)); + if (cachedReferenceList) { + cachedReferenceList.push(id); + this.cache_.put(DataStore.REFERENCE_LIST_CACHE_KEY, + JSON.stringify(cachedReferenceList)); + } + } + + this.cache_.put(DataStore.REFERENCE_CACHE_KEY_PREFIX + id, + JSON.stringify(reference)); + + return id; +}; + + +/** + * Helper method that creates a generic JavaScript object from a + * {@code ScriptDbMap}. We preform this seemingly unnecessary conversion so all + * {@code ScriptDb} objects (which could otherwise be used to mutate the + * datastore directly, potentially causing an inconsistent cache state) are + * confined to the {@code DataStore} class. + * @param {!ScriptDb.ScriptDbMap} referenceRecord The raw {@code ScriptDb} + * record. + * @return {!DataStore.Reference} A plain old JavaScript object containing the + * same properties as the {@code ScriptDb}, and also having its {@code id} + * property set to the original record's {@code ScriptDb} identifier. + * @private + */ +DataStore.prototype.createModelForReferenceRecord_ = function(referenceRecord) { + // Copy the properties from the reference record to a new model object + // because we don't want to leak the underlying ScriptDbMap outside this + // class. All modification of raw ScriptDb objects should be done inside + // DataStore. + var reference = {id: referenceRecord.getId()}; + for (var key in referenceRecord) { + if (referenceRecord.hasOwnProperty(key)) { + reference[key] = referenceRecord[key]; + } + } + return reference; +}; + + +/** + * Determines whether or not the given object represents a valid reference + * model. This is the last line of against corrupt data ending up in the data + * store (e.g., because a mischievous user handcrafted a + * {@code google.script.run} call to bypass client-side form validation). + * @param {!DataStore.Reference} reference The reference object to validate. + * @return {boolean} True if the reference is valid and can be stored, false + * otherwise. + * @private + */ +DataStore.prototype.validateReferenceModel_ = function(reference) { + if (!(reference['authors'] instanceof Array)) { + return false; + } + var authors = reference['authors']; + for (var i = 0; i < authors.length; ++i) { + var author = authors[i]; + if (typeof author['lastName'] != 'string' || + typeof author['firstName'] != 'string') { + return false; + } + } + + return (reference['kind'] == DataStore.ReferenceKind.ARTICLE || + reference['kind'] == DataStore.ReferenceKind.BOOK) && + typeof reference['title'] == 'string' && + reference['title'] != '' && + typeof reference['publicationYear'] == 'number' && + reference['publicationYear'] > 0 && + (reference['publication'] === undefined || + typeof reference['publication'] == 'string') && + (reference['journalVolume'] === undefined || + typeof reference['journalVolume'] == 'string') && + (reference['journalIssue'] === undefined || + typeof reference['journalIssue'] == 'string') && + (reference['startPage'] === undefined || + typeof reference['startPage'] == 'number') && + (reference['endPage'] === undefined || + typeof reference['endPage'] == 'number') && + (reference['edition'] === undefined || + typeof reference['edition'] == 'string') && + (reference['volume'] === undefined || + typeof reference['volume'] == 'string') && + (reference['publisher'] === undefined || + typeof reference['publisher'] == 'string') && + (reference['publication'] === undefined || + typeof reference['publication'] == 'string'); +}; + + +/** + * Internal helper method to load a list of records from the database backend. + * @param {!Object} query The {@code ScriptDb} query to execute. This is a + * partially complete template object whose defined fields will be compared + * with the complete objects in the data store. Please see the + * {@code ScriptDb} documentation online for some examples. + * @return {!Array.} A list of all {@code ScriptDb} + * records matching the specified query. + * @private + */ +DataStore.prototype.queryRecords_ = function(query) { + var records = []; + var results = this.scriptDb_.query(query); + while (results.hasNext()) { + records.push(results.next()); + } + return records; +}; + + +/** + * Internal helper method that loads a single record from the database backend. + * This method will throw an exception if more than one record matches that + * query. + * @param {!Object} query The {@code ScriptDb} query to execute. This is a + * partially complete template object whose defined fields will be compared + * with the complete objects in the data store. Please see the + * {@code ScriptDb} documentation online for some examples. + * @return {ScriptDb.ScriptDbMap} The single {@code ScriptDb} record matching + * the specified query, or null if no records matched the query. + * @private + */ +DataStore.prototype.queryRecord_ = function(query) { + var results = this.scriptDb_.query(query); + if (results.getSize() > 1) { + throw new Error(Utilities.formatString( + 'Expected at most one record for query %s, got %d', + JSON.stringify(query), results.getSize())); + } + return results.hasNext() ? results.next() : null; +}; diff --git a/bibstro/documentmodel.gs b/bibstro/documentmodel.gs new file mode 100644 index 000000000..cc527e9d1 --- /dev/null +++ b/bibstro/documentmodel.gs @@ -0,0 +1,398 @@ +// Copyright 2013 Google Inc. All Rights Reserved. +// +// 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. + +/** + * @fileOverview Google Apps Script and Google Docs demo: Bibstro. Document + * model class that abstracts away all direct interactions with Google Docs. + * This class is responsible for inserting inline citations at the cursor + * location, finding and updating the live list of references at the end of the + * document, and importing existing references from a new document into + * Bibstro's data store. + * @author Jonathan Rascher + * @author Saurabh Gupta + */ + + + +/** + * Constructs a new {@code DocumentModel} that wraps the specified Google Docs + * document. (Generally, this will be the active document.) + * @param {!DocumentApp.Document} doc + * @constructor + */ +var DocumentModel = function(doc) { + /** + * The document to be read and written by the Bibstro app. + * @type {!DocumentApp.Document} + * @private + */ + this.doc_ = doc; +}; + + +/** + * Inserts an inline citation at the document's current cursor location. + * @param {!BibStrategy} bibStrategy The current bibliography strategy (citation + * format) used to construct the inline citation text for insertion. + * @param {!DataStore.Reference} reference The reference to be cited. + * @param {number} startPage The first page included in this citation. + * @param {number} endPage The last page included in this citation. + * @param {boolean} firstMention Whether or not this reference has been cited + * earlier in the document. For certain citation formats (e.g., APA), the + * first citation associated with a given reference must contain information + * that should be omitted in later citations. + * @param {boolean} abbreviateCitation If true, the inserted inline citation + * will be abbreviated somehow. Exactly how depends on the specific citation + * format selected; however, in most formats the author(s) will be omitted + * and only the page number(s) will be included. + */ +DocumentModel.prototype.insertCitationAtCursor = function(bibStrategy, + reference, startPage, endPage, firstMention, abbreviateCitation) { + var cursor = this.doc_.getCursor(); + var insertedText; + + if (cursor) { + // Determine the text of the new inline citation to insert. + var citation = bibStrategy.getInlineCitationText(reference, startPage, + endPage, firstMention, abbreviateCitation); + + var surroundingText = cursor.getSurroundingText().getText(); + var surroundingTextOffset = cursor.getSurroundingTextOffset(); + + if (surroundingTextOffset > 0 && + surroundingText.charAt(surroundingTextOffset - 1) != ' ') { + // If the cursor follows a non-space character, insert a space and then + // the citation. + insertedText = cursor.insertText(' ' + citation); + } else { + // Otherwise, just insert the citation. + insertedText = cursor.insertText(citation); + } + } + + // Not all cursor locations allow text insertion. If we couldn't insert the + // inline citation, throw an appropriate error. + if (!insertedText) { + throw new Error("Cannot insert text at the cursor location"); + } +}; + + +/** + * @param {!BibStrategy} The bibliography strategy to be used to determine + * matching references according to the current citation format. + * @param {!Array} All possible references in the + * document's bibliography. + * @param {!DataStore.Reference} The particular reference we're looking for. + * @param {boolean=} opt_unhighlight If true, matching citation highlights will + * be cleared rather than set. + */ +DocumentModel.prototype.highlightMatchingCitations = function(bibStrategy, + references, reference, opt_unhighlight) { + // Determine the correct background to apply to matching inline citations. + var attrs = {}; + attrs[DocumentApp.Attribute.BACKGROUND_COLOR] = opt_unhighlight ? + '#ffffff' /* white */ : '#ffff00' /* yellow */; + + // Determine all possible authors to search for before inline citations. + var authors = []; + for (var i = 0; i < references.length; ++i) { + for (var j = 0; j < references[i].authors.length; ++j) { + authors.push(references[i].authors[j]); + } + } + + // For each matching inline citation, apply the background color changes. + this.forEachCitation_( + bibStrategy.getBibliographyTitleText(), + authors, + function(bodyText, citationIndex, citationStr, prevAuthor) { + if (bibStrategy.testInlineCitationMatch(reference, prevAuthor, + citationStr)) { + bodyText.setAttributes(citationIndex, + citationIndex + citationStr.length - 1, attrs); + } + }); +}; + + +/** + * Updates the content's of the document's bibliography with new references from + * the data store, inserting a new bibliography first if necessary. + * @param {!BibStrategy} bibStrategy The current bibliography strategy or + * citation format. This is used to identify the bibliography by looking for + * an appropriately titled header, and also to correctly format new + * bibliography entries. + * @param {!Array.} references All the references or + * sources current stored in the bibliography application's data store. + * @param {boolean=} opt_updateOnly If true, a new bibliography will not be + * appended to the document if no existing bibliography can be found. + * @param {!BibStrategy=} opt_prevBibStrategy The previous bibliography + * strategy, if the user saved configured changes. This is used to find and + * remove the old strategy's bibliography from the document before adding a + * new bibliography using the new strategy (citation format). + */ +DocumentModel.prototype.appendOrUpdateBibliography = function(bibStrategy, + references, opt_updateOnly, opt_prevBibStrategy) { + var prevBibStrategy = opt_prevBibStrategy || bibStrategy; + + // Look for an existing bibliography to update so we don't append a second + // set of bibliography elements in that case. + var titleParagraph = this.findBibliography_(prevBibStrategy); + + if (!titleParagraph) { + // If we didn't find an existing bibliography, either bail out without + // changing the document, or append a new paragraph to serve as the live + // bibliography in the future. + if (opt_updateOnly) { + return; + } + titleParagraph = this.doc_. + getBody(). + appendParagraph(bibStrategy.getBibliographyTitleText()). + setAlignment(DocumentApp.HorizontalAlignment.CENTER); + titleParagraph.insertPageBreak(0); + } else if (bibStrategy.getName() != prevBibStrategy.getName()) { + // Otherwise, if we found an existing live bibliography and we've changed + // citation formats, we might have to update the bibliography title. + titleParagraph.setText(bibStrategy.getBibliographyTitleText()); + titleParagraph.insertPageBreak(0); + } + + this.updateBibliography_(bibStrategy, references, titleParagraph); +}; + + +/** + * Attempts to import existing references by extracting them from the document's + * bibliography using regular expressions and text manipulation. Naturally, this + * is not guaranteed to successfully return data for all references. + * @param {!BibStrategy} bibStrategy The current bibliography strategy or + * citation format. This is used to identify the bibliography by looking for + * an appropriately titled header, and also to determine which citation + * format to use when extracting bibliography entries. + * @return {!Array.} A list of all references that could + * be successfully extracted. + */ +DocumentModel.prototype.extractReferences = function(bibStrategy) { + var referenceParagraph = this.findBibliography_(bibStrategy); + + // If we couldn't even find a reference list in the document, there's no way + // we can import anything. + if (!referenceParagraph) { + return []; + } + + // Otherwise, import whatever we can figure out. + var references = []; + + while (referenceParagraph = referenceParagraph.getNextSibling()) { + if (referenceParagraph.getType() != DocumentApp.ElementType.PARAGRAPH) { + break; + } + + var referenceTextElement = referenceParagraph.editAsText(); + var referenceText = referenceTextElement.getText(); + var referenceAttributeRuns = referenceTextElement.getTextAttributeIndices(); + + var tokens = []; + var prevToken = null; + for (var i = 0; i < referenceAttributeRuns.length; ++i) { + var start = referenceAttributeRuns[i]; + var end = (i + 1 < referenceAttributeRuns.length) ? + referenceAttributeRuns[i + 1] : referenceText.length; + + // Ignore all style expect transitions between italic and roman text. + var text = referenceText.substring(start, end); + var publicationTitle = referenceTextElement.isItalic(start); + + if (prevToken && prevToken.publicationTitle == publicationTitle) { + prevToken.text += text; + } else { + prevToken = {text: text, publicationTitle: publicationTitle}; + tokens.push(prevToken); + } + } + + var reference = bibStrategy.getReferenceForBibliographyEntry(tokens); + if (reference) { + references.push(reference); + } + } + + return references; +}; + + +/** + * Applies an operation to each inline citation found in the document. + * @param {string} bibliographyTitleText The title of the bibliography, which + * indicates when to stop searching for citations. + * @param {{lastName: string, firstName: string}} authors The list of all + * authors associated with bibliography references. + * @param {function(!DocumentApp.Text, number, string, ?string)} callback The + * function to be invoked for each inline citation discovered in the + * document. The callback's function parameters are, in order, a + * {@code Text}-element view of the document's body, the index of the + * current citation in the body, the text of the current citation (including + * surrounding parentheses), and the most recently mentioned author name + * before the citation (which will be null if no authors have been mentioned + * yet). + */ +DocumentModel.prototype.forEachCitation_ = function(bibliographyTitleText, + authors, callback) { + // Extract the plain text contents of the current document's body. + var bodyText = this.doc_.getBody().editAsText(); + var bodyStr = bodyText.getText(); + + // Determine the start of the bibliography in the document so we know where to + // stop looking for inline citations. + var bibliographyTitleIndex = + bodyStr.indexOf('\n' + bibliographyTitleText + '\n'); + if (bibliographyTitleIndex == -1) { + bibliographyTitleIndex = bodyStr.length; + } + + // Build up a regular expression matching the last name of any author + // currently associated with any bibliography reference. + var authorRegExp = RegExp(authors.map(function(author) { + return escapeRegExp(author.lastName); + }).join('|'), 'g'); + + // Determine the last names and locations of all author references in the + // document. + var authorMatches = []; + var authorMatch; + while (authorMatch = authorRegExp.exec(bodyStr)) { + authorMatches.push(authorMatch); + } + + // Keep track of the last author preceding the current citation. + var prevAuthorMatch = null; + var nextAuthorMatchNumber = 0; + + // Iterate over all parenthesized portions of the document, testing if they're + // inline citations. + var citationRegExp = RegExp('\\([^)]+\\)', 'g'); + var citationMatch; + + // If we've already advanced past the start of the document's bibliography, + // stop searching. This avoids giving false positives for parenthesized text + // in bibliography entries (e.g., years for MLA format or issues in APA + // format.) + while ((citationMatch = citationRegExp.exec(bodyStr)) && + citationMatch.index < bibliographyTitleIndex) { + // Find the last author preceding the current citation, if any. This will be + // used to guess the appropriate reference if the current citation doesn't + // directly contain the author's name. + while ((prevAuthorMatch == null || + prevAuthorMatch.index < citationMatch.index) && + nextAuthorMatchNumber < authorMatches.length && + authorMatches[nextAuthorMatchNumber].index < citationMatch.index) { + prevAuthorMatch = authorMatches[nextAuthorMatchNumber++]; + } + + // Invoke the callback function for the current inline citation. + callback(bodyText, citationMatch.index, citationMatch[0], + prevAuthorMatch ? prevAuthorMatch[0] : null); + } +}; + + +/** + * Finds the bibliography at the end of the current document. + * @param {!BibStrategy} bibStrategy The current bibliography strategy or + * citation format. This is used to identify the bibliography by looking for + * an appropriately titled header like "Works Cited". + * @return {DocumentApp.Paragraph} The paragraph containing the bibliography + * title text. The actual bibliography entries will follow this title + * paragraph. The return value will be null if no bibliography is found in + * the document at this time. + */ +DocumentModel.prototype.findBibliography_ = function(bibStrategy) { + var titleParagraph = null; + + var body = this.doc_.getBody(); + var searchResult = null; + while (searchResult = body.findElement(DocumentApp.ElementType.PARAGRAPH, + searchResult)) { + var paragraph = searchResult.getElement().asParagraph(); + if (paragraph.editAsText().getText() == + bibStrategy.getBibliographyTitleText()) { + titleParagraph = paragraph; + break; + } + } + + return titleParagraph; +}; + + +/** + * Updates the content's of the document's bibliography with new references from + * the app's data store. + * @param {!BibStrategy} bibStrategy The current bibliography strategy or + * citation format. This is used to identify the bibliography by looking for + * an appropriately titled header, and also to correctly format new + * bibliography entries. + * @param {!Array.} references All the references or + * sources current stored in the bibliography application's data store. + * @param {!DocumentApp.Paragraph} titleParagraph The bibliography's title + * paragraph found, as determined by {@code findBibliography_}. + * @private + */ +DocumentModel.prototype.updateBibliography_ = function(bibStrategy, references, + titleParagraph) { + // Remove all references from the document's bibliography. + var referenceParagraph; + while (referenceParagraph = titleParagraph.getNextSibling()) { + if (referenceParagraph.getType() != DocumentApp.ElementType.PARAGRAPH) { + break; + } + // While there are still bibliography entry paragraphs after the bibliograpy + // title, remove the next entry's content and merge the title into the (now + // empty) paragraph. We also need to reset the bibliography title's style + // attributes post-merge. + referenceParagraph. + clear(). + setAlignment(DocumentApp.HorizontalAlignment.CENTER). + setIndentStart(0). + merge(); + } + + // Add the latest reference data to the document's bibliography. + references.sort(bibStrategy.getReferenceComparator()); + + var bibParent = titleParagraph.getParent(); + var nextIndex = bibParent.getChildIndex(titleParagraph); + for (var i = 0; i < references.length; ++i) { + // For each bibliography entry, append a new paragraph with a half-inch + // hanging indent. + referenceParagraph = bibParent.insertParagraph(++nextIndex, ''). + setIndentStart(36 /* pt, or 0.5 in */). + setIndentFirstLine(0); + + // Next, get the formatted bibliography entry as an array of + // BibStrategy.BibEntryToken objects; then append the tokens' contents to + // the current reference paragraph. Each token can have a different style; + // for example, tokens representing book or journal titles will be + // italicized when added to the document. + var tokens = bibStrategy.getBibliographyEntry(references[i]); + for (var j = 0; j < tokens.length; ++j) { + var token = tokens[j]; + var tokenText = referenceParagraph.appendText(token.text); + tokenText.setItalic(token.publicationTitle); + } + } +}; diff --git a/bibstro/events.gs b/bibstro/events.gs new file mode 100644 index 000000000..48df2b4ae --- /dev/null +++ b/bibstro/events.gs @@ -0,0 +1,371 @@ +// Copyright 2013 Google Inc. All Rights Reserved. +// +// 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. + +/** + * @fileOverview Google Apps Script and Google Docs demo: Bibstro. Event + * dispatcher. The scripting infrastructure calls function in this file to + * handle events like "document opened" or "menu item clicked", and this file + * invokes the appropriate methods on the {@code Controller} class. + * @author Jonathan Rascher + * @author Saurabh Gupta + */ + + +// This application loosely follows the model-view-controller pattern. Requests +// from the client are dispatched into this file (events.gs) and funnelled into +// the Controller class. At that point, the following model classes into play: +// +// * DataStore: Stores bibliography references (called "sources" in the user +// interface) in the ScriptDb NoSQL database with a write-through cache based on +// CacheService. +// +// * BibStrategy: Represents the current bibliography strategy or citation +// format. This class encapsulates all logic for converting references between +// format-agnostic DataStore model objects and format-specific text entries. +// +// * DocumentModel: Encapsulates all code that directly interacts with the +// current Google Docs document. This class is responsible for maintaining the +// bibliography at the end of the document, adding and highlighting inline +// citations, etc. +// +// Some additional, shared functions live in common.gs. Finally, bibliography +// data and configuration are displayed and edited using the following view +// HtmlService templates: +// +// * configdialog.html: Configuration settings live here. +// +// * managereferencessidebar.html: This shows the user their current references +// (bibliography sources) according to the selected citation format, allows the +// user to add and edit references, etc. +// +// * referencedialog.html: Contains form fields and validation logic needed for +// adding new references and updating existing references. +// +// * citationdialog.html: This dialog lets the user insert an inline citation at +// the document's current cursor location. + + +/** + * Handles application initialization when Google Docs is opened. + */ +function onOpen() { + // Add some menu items to the Google Docs word processor. These menu items + // call into the Apps Script handlers listed below when clicked. + DocumentApp.getUi().createMenu('Bibstro'). + addItem('Add inline citation...', addCitationMenuItem_onClick.name). + addItem('Refresh document bibliography', + appendLiveBibMenuItem_onClick.name). + addSeparator(). + addItem('Add new source...', addReferenceMenuItem_onClick.name). + addItem('Manage sources', manageReferencesMenuItem_onClick.name). + addItem('Import existing bibliography', + importReferencesMenuItem_onClick.name). + addSeparator(). + addSubMenu(DocumentApp.getUi().createMenu('Settings'). + addItem('Configure', configureMenuItem_onClick.name). + addItem('Reset data store [debug]...', + resetDataStoreMenuItem_onClick.name)). + addItem('About Bibstro', aboutMenuItem_onClick.name). + addToUi(); +} + + +/** + * Inserts an inline citation into the document at the current cursor location + * using the bibliography reference and page numbers specified by the user. + * @param {!Object.} Populated form fields from {@code + * citationdialog.html}. + */ +function onInsertCitation(e) { + new Controller().insertCitation(e['referenceId'], Number(e['startPage']), + Number(e['endPage']), !!e['firstMention'], !!e['abbreviateCitation']); +} + + +/** + * Shows a dialog allowing a user to insert a new reference into the + * bibliography or to edit an existing reference. + * @param {string=} opt_id If present, the {@code ScriptDb} identifier of the + * reference whose data should be fetched and entered into the reference + * dialog form for editing. If absent, then the form fields shall be left + * blank. + */ +function onReferenceShow(opt_id) { + new Controller().showReferenceDialog(opt_id); +} + + +/** + * Removes the reference with the specified ID from the underlying data store + * when the user clicks the reference sidebar's "remove" button. + * @param {string} id The {@code ScriptDb} identifier of the reference record to + * be removed. + */ +function onReferenceRemove(id) { + new Controller().deleteReference(id); +} + + +/** + * Toggles highlighting of inline citations in the document associated with the + * specified reference. + * @param {{id: string, unhighlight: boolean}} e An object identifying the + * reference in question and whether highlights of associated citations + * should be set or cleared. + */ +function onReferenceHighlight(e) { + new Controller().highlightReference(e['id'], e['unhighlight']); +} + + +/** + * Processes a form submission from the reference dialog, saving data entered by + * the user into the bibliography backend's {@code ScriptDb} data store. + * @param {!Object.} e Form field values from + * {@code referencedialog.html}. + */ +function onReferenceSave(e) { + var reference = {}; + if (e['id']) { + reference.id = e['id']; + } + reference.kind = e['kind']; + + // Author fields have names like 'lastName[0]', 'firstName[0]', 'lastName[1]', + // 'firstName[1]', etc. We need to convert these to an array of author objects + // having keys named 'lastName' and 'firstName'. (If we named all our form + // fields just 'lastName' and 'firstName', Apps Script would provide us with + // two parallel arrays for free, but unfortunately the jQuery Validate plugin + // does not support form fields with duplicate names.) + var lastNames = extractArrayFields(e, 'lastName'); + var firstNames = extractArrayFields(e, 'firstName'); + + reference.authors = []; + for (var i = 0; i < lastNames.length; ++i) { + // The arrays passed up from the client can have gaps in them if author rows + // are removed from the form before it is submitted. + if (lastNames[i] != undefined && firstNames[i] != undefined) { + reference.authors.push({ + lastName: lastNames[i], + firstName: firstNames[i] + }); + } + } + + reference.title = e['title']; + reference.publicationYear = Number(e['publicationYear']); + + // We don't do any real server-side validation here; the client-side + // validation code should prevent non-malicious/mischievous users from causing + // trouble, and DataStore itself contains code to prevent saving objects that + // violate the underlying data model. + switch (e.kind) { + case DataStore.ReferenceKind.ARTICLE: + reference.publication = e['publication']; + if (e['journalVolume']) { + reference.journalVolume = e['journalVolume']; + } + if (e['journalIssue']) { + reference.journalIssue = e['journalIssue']; + } + reference.startPage = Number(e['startPage']); + reference.endPage = Number(e['endPage']); + break; + + case DataStore.ReferenceKind.BOOK: + if (e['edition']) { + reference.edition = e['edition']; + } + if (e['volume']) { + reference.volume = e['volume']; + } + reference.publisher = e['publisher']; + reference.publisherCity = e['publisherCity']; + break; + } + + new Controller().saveReference(reference); +} + + +/** + * Shows a configuration dialog ({@code configdialog.html}) allowing the user to + * set the current citation format. + */ +function onAppendLiveBibliography() { + new Controller().appendLiveBibliography(); +} + + +/** + * Persists possibly updated configuration options back to the {@code ScriptDb} + * data store. + * @param {!Object.} e Form field values from + * {@code configdialog.html}. + */ +function onConfigSave(e) { + var controller = new Controller(); + var prevBibStrategy = controller.getBibStrategy(); + + controller.saveConfig(e); + + if (e['initialSetup']) { + if (e['importReferences']) { + controller.importReferencesFromDocument(); + } + } else { + controller.refreshLiveBibliography(prevBibStrategy || undefined); + } +} + + +/** + * Loads the full list of references from the data store, producing properly + * formatted HTML bibliography entries for each. + * @return {{bibStrategy: string, + * references: !Array.<{id: string, html: string}>, + * highlights: !Array.}} + */ +function loadReferenceData() { + return new Controller().loadReferenceData(); +} + + +/** + * Shows {@code citationdialog.html} in response to a menu item click. + */ +function addCitationMenuItem_onClick() { + var controller = new Controller(); + if (controller.showInitialSetupDialog()) { + return; + } + controller.showCitationDialog(); +} + + +/** + * Appends a live-updating, formatted bibliography to the Google Docs document + * or forces an update of the existing bibliography if one already exists. + */ +function appendLiveBibMenuItem_onClick() { + var controller = new Controller(); + if (controller.showInitialSetupDialog()) { + return; + } + controller.appendLiveBibliography(); +} + + +/** + * Shows {@code referencedialog.html} in response to a menu item click. + */ +function addReferenceMenuItem_onClick() { + var controller = new Controller(); + if (controller.showInitialSetupDialog()) { + return; + } + controller.showReferenceDialog(); +} + + +/** + * Shows {@code managereferencessidebar.html} in response to a menu item click. + */ +function manageReferencesMenuItem_onClick() { + var controller = new Controller(); + if (controller.showInitialSetupDialog()) { + return; + } + controller.showManageReferenceSidebar(); +} + + +/** + * Attempts to import reference from an existing document bibliography into the + * document's data store. + */ +function importReferencesMenuItem_onClick() { + var controller = new Controller(); + if (controller.showInitialSetupDialog()) { + return; + } + controller.importReferencesFromDocument(); +} + + +/** + * Shows {@code configdialog.html} in response to a menu item click. + */ +function configureMenuItem_onClick() { + var controller = new Controller(); + if (controller.showInitialSetupDialog()) { + return; + } + controller.showConfigDialog(); +} + + +/** + * Irreversibly removes all objects from {@code ScriptDb}. This is intended to + * allow reseting the data store for debugging/development purposes. Since this + * operation could lead to unwanted data loss, we ask the user for confirmation + * before deleting anything. + */ +function resetDataStoreMenuItem_onClick() { + var ui = DocumentApp.getUi(); + var confirmation = ui.alert( + 'Google Apps Script and Google Docs demo: Bibstro', + 'This will erase all sources and configuration! Are you sure?', + ui.ButtonSet.YES_NO); + + if (confirmation == ui.Button.YES) { + var scriptDb = ScriptDb.getMyDb(); + var cache = CacheService.getPublicCache(); + + var results = scriptDb.query({}); + while (results.hasNext()) { + var result = results.next(); + + // Remove record from the data store and the write-through cache. + scriptDb.remove(result); + cache.remove(DataStore.REFERENCE_CACHE_KEY_PREFIX + result.getId()); + } + + // Clear out the write-through cache's list of active reference IDs. + cache.remove(DataStore.REFERENCE_LIST_CACHE_KEY); + + // Clear out the write-through cache's list of highlighted reference IDs. + cache.remove(DataStore.HIGHLIGHT_LIST_CACHE_KEY); + + ui.alert( + 'Google Apps Script and Google Docs demo: Bibstro', + 'All bibliography data has been erased. Please reload the document.', + ui.ButtonSet.OK); + } +} + + +/** + * Shows a simple "about" dialog to the user. + */ +function aboutMenuItem_onClick() { + DocumentApp.getUi().alert( + 'Google Apps Script and Google Docs demo: Bibstro', + 'Bibstro is a simple bibliography manager and citation formatting tool ' + + 'for Google Docs. Developed for Google I/O 2013, Bibstro is built ' + + 'in Google Apps Script. Bibstro is only a demo, but it attempts to ' + + 'show how developers can solve real-world problems using the Apps ' + + 'Script platform.', + DocumentApp.getUi().ButtonSet.OK); +} diff --git a/bibstro/managereferencessidebar.html b/bibstro/managereferencessidebar.html new file mode 100644 index 000000000..8dd772378 --- /dev/null +++ b/bibstro/managereferencessidebar.html @@ -0,0 +1,89 @@ + +Google Apps Script and Google Docs demo: Bibstro + + + + +

+ Loading... +
+
    +
    + +
    + + +
    + + diff --git a/bibstro/managereferencessidebar_js.html b/bibstro/managereferencessidebar_js.html new file mode 100644 index 000000000..2d7c01c85 --- /dev/null +++ b/bibstro/managereferencessidebar_js.html @@ -0,0 +1,112 @@ + + diff --git a/bibstro/referencedialog.html b/bibstro/referencedialog.html new file mode 100644 index 000000000..31f6cdc37 --- /dev/null +++ b/bibstro/referencedialog.html @@ -0,0 +1,182 @@ + +Google Apps Script and Google Docs demo: Bibstro + + + + +
    +
    + Add a newEdit an existing source + + + +
    + +
    + +
    +
    + + +
    + +
    + + + +
    +
    + + +
    +
    + +
    +
    + +
    + +
    + +
    +
    + +
    style="display: none;"> + +
    + +
    +
    + +
    style="display: none;"> + +
    + + ed., Vol. + +
    +
    + +
    style="display: none;"> + +
    + + . + +
    +
    + +
    + +
    + +
    +
    + +
    style="display: none;"> + +
    + +
    +
    + +
    style="display: none;"> + +
    + +
    +
    + +
    style="display: none;"> + +
    + + – + +
    +
    +
    + +
    + + +
    +
    + + diff --git a/bibstro/referencedialog_js.html b/bibstro/referencedialog_js.html new file mode 100644 index 000000000..70b1717bb --- /dev/null +++ b/bibstro/referencedialog_js.html @@ -0,0 +1,120 @@ + + + + From b542f48b7fe582854ccae8de00830190a5032ed2 Mon Sep 17 00:00:00 2001 From: Jonathan Rascher Date: Mon, 24 Jun 2013 20:29:15 -0400 Subject: [PATCH 2/2] Use /view rather than /edit for Bibstro sample doc --- bibstro/README.markdown | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bibstro/README.markdown b/bibstro/README.markdown index ecd62171a..2c314a664 100644 --- a/bibstro/README.markdown +++ b/bibstro/README.markdown @@ -22,5 +22,5 @@ as Apps Script's [API reference][api_reference]. [docs]: https://support.google.com/drive/answer/143206?ref_topic=21008&rd=1 [io_video]: https://www.youtube.com/watch?v=KIiCSdRCqXc [quickstart]: https://developers.google.com/apps-script/guides/docs -[sample_doc]: https://docs.google.com/document/d/1akzFJ9_5pABrk4NFxLDZX7DymObYfIfGZ3CTZ2CgHSY/edit +[sample_doc]: https://docs.google.com/document/d/1akzFJ9_5pABrk4NFxLDZX7DymObYfIfGZ3CTZ2CgHSY/view [screenshot]: https://googledrive.com/host/0B86sei6ZHtsdVF9iaFd5cTdiNVk/bibstro-readme-screenshot.png