diff --git a/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx index af68976d..5802e46c 100644 --- a/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx +++ b/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as fs from "fs"; import * as Path from "path"; -import * as ReactModal from "react-modal"; +import ReactModal from "react-modal"; import "./ConfirmDeleteDialog.scss"; import CloseOnEscape from "react-close-on-escape"; import { locate } from "../../crossPlatformUtilities"; diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index bbb2775a..5ad75a92 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -5,7 +5,7 @@ import { computed } from "mobx"; import { observer, Observer } from "mobx-react"; import { Folder } from "../model/Folder"; import { File } from "../model/file/File"; -import * as Dropzone from "react-dropzone"; +import Dropzone from "react-dropzone"; import { remote } from "electron"; const moment = require("moment"); import { Dictionary } from "typescript-collections"; diff --git a/app/components/export/ExportDialog.tsx b/app/components/export/ExportDialog.tsx index 6f7a48d9..82a14c83 100644 --- a/app/components/export/ExportDialog.tsx +++ b/app/components/export/ExportDialog.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import * as ReactModal from "react-modal"; +import ReactModal from "react-modal"; import "./ExportDialog.scss"; import CloseOnEscape from "react-close-on-escape"; import CsvExporter from "../../export/CsvExporter"; diff --git a/app/components/people/person/MugShot.tsx b/app/components/people/person/MugShot.tsx index 59e6ad03..0e3df5e0 100644 --- a/app/components/people/person/MugShot.tsx +++ b/app/components/people/person/MugShot.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { observer } from "mobx-react"; import { Person } from "../../../model/Project/Person/Person"; -import * as Dropzone from "react-dropzone"; +import Dropzone from "react-dropzone"; import * as fs from "fs-extra"; import { MugshotPlaceholder } from "./MugshotPlaceholder"; import ImageField from "../../ImageField"; diff --git a/app/components/project/CreateProjectDialog.tsx b/app/components/project/CreateProjectDialog.tsx index fb513aa6..52b77e36 100644 --- a/app/components/project/CreateProjectDialog.tsx +++ b/app/components/project/CreateProjectDialog.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as fs from "fs"; import * as Path from "path"; -import * as ReactModal from "react-modal"; +import ReactModal from "react-modal"; import "./CreateProjectDialog.scss"; const { app } = require("electron").remote; diff --git a/app/model/Folder.ts b/app/model/Folder.ts index e3e24435..74e72cbe 100644 --- a/app/model/Folder.ts +++ b/app/model/Folder.ts @@ -12,20 +12,23 @@ import * as fs from "fs-extra"; import * as Path from "path"; import * as glob from "glob"; import { FieldSet } from "./field/FieldSet"; -import * as assert from "assert"; +import assert from "assert"; import ConfirmDeleteDialog from "../components/ConfirmDeleteDialog/ConfirmDeleteDialog"; const sanitize = require("sanitize-filename"); import { trash } from "../crossPlatformUtilities"; export class IFolderSelection { - @observable public index: number; + @observable + public index: number; } // Project, Session, or Person export abstract class Folder { public directory: string = ""; - @observable public files: File[] = []; - @observable public selectedFile: File | null; + @observable + public files: File[] = []; + @observable + public selectedFile: File | null; public metadataFile: File | null; protected safeFileNameBase: string; diff --git a/app/model/Project/ReadProject.spec.ts b/app/model/Project/ReadProject.spec.ts new file mode 100644 index 00000000..f9da689d --- /dev/null +++ b/app/model/Project/ReadProject.spec.ts @@ -0,0 +1,57 @@ +import { ProjectMetadataFile } from "../Project/Project"; +import * as temp from "temp"; +import fs from "fs"; +import Path from "path"; + +let projectDirectory; +let projectName; + +describe("Project Read", () => { + beforeEach(async () => { + projectDirectory = temp.mkdirSync("test"); + projectName = Path.basename(projectDirectory); + }); + afterEach(async () => { + temp.cleanupSync(); + }); + it("should read title", () => { + const f = GetProjectFileWithOneField("Title", "This is the title."); + expect(f.getTextProperty("title")).toBe("This is the title."); + }); + it("empty date should just be empty string", () => { + const f = GetProjectFileWithOneField("DateAvailable", ""); + expect(f.properties.getDateField("dateAvailable").asISODateString()).toBe( + "" + ); + }); + it("should read iso date properly", () => { + const f = GetProjectFileWithOneField("DateAvailable", "2015-03-05"); + expect(f.properties.getDateField("dateAvailable").asISODateString()).toBe( + "2015-03-05" + ); + }); + it("should read iso date with time and offset properly", () => { + const f = GetProjectFileWithOneField( + "DateAvailable", + "2016-01-01T00:00:00+02:00" + ); + // The original says it's at midnight in a timezone 2 hours ahead of UTC. + // In SayMore we don't want to deal with timezones, so we convert that to + // UTC, which is actually the previous day, drop the time, drop the time offset. + expect(f.properties.getDateField("dateAvailable").asISODateString()).toBe( + "2015-12-31" + ); + }); +}); + +function GetProjectFileWithOneField( + tag: string, + content: string +): ProjectMetadataFile { + fs.writeFileSync( + Path.join(projectDirectory, projectName + ".sprj"), + ` + <${tag}>${content}` + ); + return new ProjectMetadataFile(projectDirectory); +} diff --git a/app/model/field/Field.ts b/app/model/field/Field.ts index fc90f92b..bed68150 100644 --- a/app/model/field/Field.ts +++ b/app/model/field/Field.ts @@ -66,7 +66,8 @@ export class Field { public readonly visibility: FieldVisibility; public persist: boolean; public readonly cssClass: string; - @observable public textHolder = new TextHolder(); + @observable + public textHolder = new TextHolder(); public choices: string[]; public definition: FieldDefinition; public contributorsArray: Contribution[]; //review @@ -145,11 +146,17 @@ export class Field { // //imdiIsClosedVocabulary?: boolean; // isCustom: false // }; - assert.ok( - this.key.toLowerCase().indexOf("date") === -1 || - this.type === FieldType.Date, - "SHOULDN'T " + key + " BE A DATE?" - ); + //assert.ok( + // this.key.toLowerCase().indexOf("date") === -1 || + // this.type === FieldType.Date, + // "SHOULDN'T " + key + " BE A DATE?" + //); + if ( + this.key.toLowerCase().indexOf("date") > -1 && + this.type !== FieldType.Date + ) { + console.error(key + " should be a date?"); + } } get text(): string { @@ -165,21 +172,27 @@ export class Field { this.text = s; } - public asDate(): Date { - return new Date(Date.parse(this.text)); - } + // public asDate(): Date { + // const x = new Date("2015-03-25Z"); + // const y = x.getUTCDate(); + // const z = this.text.indexOf("Z") > -1 ? this.text : this.text + "Z"; + // return new Date(this.text); + // } public asISODateString(): string { - if (moment(this.text).isValid()) { - return this.asDate().toISOString(); - } - return ""; + // our rule is that we always keep strings in "YYYY-MM-DD" format, and it's always UTC + return this.text; } public asLocaleDateString(): string { - if (moment(this.text).isValid()) { - return this.asDate().toLocaleDateString(); - } - return ""; + // if (moment(this.text).isValid()) { + // return this.asDate().toLocaleDateString(); + // } + // return ""; + + // maybe someday. But at the moment, javascript's date stuff is so eager to get into timezones + // that it's introducing buts. So for now let' keep it simple by just sticking to storing dates only as + // "YYYY-MM-DD" format string, and always UTC + return this.text; } public typeAndValueForXml(): [string, string] { switch (this.type) { diff --git a/app/model/field/FieldSet.ts b/app/model/field/FieldSet.ts index 1c55fcd1..b2568b4f 100644 --- a/app/model/field/FieldSet.ts +++ b/app/model/field/FieldSet.ts @@ -1,5 +1,5 @@ import { Dictionary } from "typescript-collections"; -import * as assert from "assert"; +import assert from "assert"; import { Field, FieldType, FieldDefinition, HasConsentField } from "./Field"; import { Contribution } from "../file/File"; import { Person } from "../Project/Person/Person"; @@ -47,11 +47,11 @@ export class FieldSet extends Dictionary { public addHasConsentProperty(person: Person) { this.setValue("hasConsent", new HasConsentField(person)); } - public manditoryTextProperty(key: string, value: string) { - if (!this.containsKey(key)) { - this.setValue(key, new Field(key, FieldType.Text, value)); - } - } + // public manditoryTextProperty(key: string, value: string) { + // if (!this.containsKey(key)) { + // this.setValue(key, new Field(key, FieldType.Text, value)); + // } + // } // public manditoryField(field: Field) { // if (this.containsKey(field.key)) { // const existing = this.getValue(field.key); diff --git a/app/model/file/File.ts b/app/model/file/File.ts index cb52d493..76054836 100644 --- a/app/model/file/File.ts +++ b/app/model/file/File.ts @@ -3,22 +3,26 @@ import * as fs from "fs"; import * as Path from "path"; const filesize = require("filesize"); import * as mobx from "mobx"; -import * as assert from "assert"; +import assert from "assert"; const camelcase = require("camelcase"); const imagesize = require("image-size"); import { Field, FieldType, FieldDefinition } from "../field/Field"; import { FieldSet } from "../field/FieldSet"; import * as xmlbuilder from "xmlbuilder"; import { locate } from "../../crossPlatformUtilities"; -const moment = require("moment"); +import moment from "moment"; const titleCase = require("title-case"); export class Contribution { //review this @mobx.observable - @mobx.observable public name: string; - @mobx.observable public role: string; - @mobx.observable public date: string; - @mobx.observable public comments: string; + @mobx.observable + public name: string; + @mobx.observable + public role: string; + @mobx.observable + public date: string; + @mobx.observable + public comments: string; } export abstract class File { @@ -28,7 +32,8 @@ export abstract class File { // In the case of folder objects (project, session, people) this will just be the metadata file, // and so describedFilePath === metadataPath. // In all other cases (mp3, jpeg, elan, txt), this will be the file we are storing metadata about. - @mobx.observable public describedFilePath: string; + @mobx.observable + public describedFilePath: string; // This file can be *just* metadata for a folder, in which case it has the fileExtensionForFolderMetadata. // But it can also be paired with a file in the folder, such as an image, sound, video, elan file, etc., @@ -40,9 +45,11 @@ export abstract class File { private fileExtensionForMetadata: string; public canDelete: boolean; - @mobx.observable public properties = new FieldSet(); + @mobx.observable + public properties = new FieldSet(); - @mobx.observable public contributions = new Array(); + @mobx.observable + public contributions = new Array(); get type(): string { const x = this.properties.getValue("type") as Field; @@ -56,12 +63,35 @@ export abstract class File { } } protected addDatePropertyFromString(key: string, dateString: string) { - // get a little paranoid with the date format - assert.ok(moment(dateString).isValid()); //todo: handle bad data - const date = new Date(Date.parse(dateString)); - this.checkType(key, date); - const dateWeTrust = date.toISOString(); - this.properties.setValue(key, new Field(key, FieldType.Date, dateWeTrust)); + // Note: I am finding it rather hard to not mess up dates, because in javascript + // everything (including moment) wants to over-think things and convert dates + // to one's local timezone. You get bugs like having the file say 2015-3-21, but + // then saving as 2015-3-20 because you're running this in America. Ugghhh. + // It's just too easy to mess up. So what I'm trying for now + // is to confine anything that could mess with the date to 2 places: here, + // at import time where we have to be permissive, and when displaying in the UI. + // Other than those two places, the rule is that all strings are YYYY-MM-DD in UTC. + + //assert.ok(moment(dateString).isValid()); //todo: handle bad data + + this.properties.setValue( + key, + new Field( + key, + FieldType.Date, + this.normalizeIncomingDateString(dateString) + ) + ); + } + protected normalizeIncomingDateString(dateString: string): string { + if (!dateString || dateString.trim().length === 0) { + return ""; + } + const date = moment(dateString); + const ISO_YEAR_MONTH_DATE_DASHES_FORMAT = "YYYY-MM-DD"; + // if there is time info, throw that away. + const standardizedDate = date.format(ISO_YEAR_MONTH_DATE_DASHES_FORMAT); + return standardizedDate; } protected addDateProperty(key: string, date: Date) { this.checkType(key, date); @@ -247,27 +277,34 @@ export abstract class File { ? key : camelcase(key); + // ---- DATES -- + if (key.toLowerCase().indexOf("date") > -1) { + const normalizedDateString = this.normalizeIncomingDateString(textValue); + if (this.properties.containsKey(fixedKey)) { + const existingDateField = this.properties.getValueOrThrow(fixedKey); + existingDateField.setValueFromString(normalizedDateString); + //console.log("11111" + key); + } else { + this.addDatePropertyFromString(fixedKey, normalizedDateString); + } + } + + // --- Text ---- // if it's already defined, let the existing field parse this into whatever structure (e.g. date) - if (this.properties.containsKey(fixedKey)) { + else if (this.properties.containsKey(fixedKey)) { const v = this.properties.getValueOrThrow(fixedKey); v.setValueFromString(textValue); //console.log("11111" + key); } else { - // Note: at least as of SayMore Windows 3.1, its files will have dates with the type "string" - // So we work around that by looking at the name of the key, to see if it contains the word "date" - if (key.toLowerCase().indexOf("date") > -1) { - this.addDatePropertyFromString(fixedKey, textValue); - } else { - //console.log("extra" + fixedKey + "=" + value); - // otherwise treat it as a string - this.addTextProperty( - fixedKey, - textValue, - true, - isCustom, - true /*showOnAutoForm*/ - ); - } + //console.log("extra" + fixedKey + "=" + value); + // otherwise treat it as a string + this.addTextProperty( + fixedKey, + textValue, + true, + isCustom, + true /*showOnAutoForm*/ + ); } } @@ -449,19 +486,20 @@ export abstract class File { ): xmlbuilder.XMLElementOrXMLNode { const ISO_YEAR_MONTH_DATE_DASHES_FORMAT = "YYYY-MM-DD"; if (dateString) { - if (moment(dateString).isValid()) { - const d = moment(dateString); - return builder - .element( - key, - // As of SayMore Windows 3.1.4, it can't handle a type "date"; it can only read and write a "string", - // so instead of the more reasonable { type: "date" }, we are using this - { type: "string" }, - d.format(ISO_YEAR_MONTH_DATE_DASHES_FORMAT) - ) - .up(); - } + // if (moment(dateString).isValid()) { + // const d = moment(dateString); + // return builder + // .element( + // key, + // // As of SayMore Windows 3.1.4, it can't handle a type "date"; it can only read and write a "string", + // // so instead of the more reasonable { type: "date" }, we are using this + // { type: "string" }, + // d.format(ISO_YEAR_MONTH_DATE_DASHES_FORMAT) + // ) + // .up(); + return builder.element(key, dateString).up(); } + return builder; // we didn't write anything } public save() { @@ -472,7 +510,7 @@ export abstract class File { //console.log(`skipping save of ${this.metadataFilePath}, not dirty`); return; } - console.log(`Saving ${this.metadataFilePath}`); + // console.log(`Saving ${this.metadataFilePath}`); const xml = this.getXml(); @@ -595,7 +633,7 @@ export abstract class File { } private clearDirty() { this.dirty = false; - console.log("dirty cleared " + this.metadataFilePath); + //console.log("dirty cleared " + this.metadataFilePath); } private changed() { diff --git a/tsconfig.json b/tsconfig.json index 0c6e5df8..591df0e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "emitDecoratorMetadata": true, "allowSyntheticDefaultImports": true, "sourceMap": true, - + "esModuleInterop": true, "outDir": "dist" }, "files": ["app/index.tsx"],