Merge pull request #39 from sismics/master

Push to production
This commit is contained in:
Benjamin Gamard 2015-11-02 21:36:14 +01:00
commit f01d78a9ea
45 changed files with 663 additions and 412 deletions

View File

@ -24,13 +24,14 @@ Features
- Support image and PDF files - Support image and PDF files
- Flexible search engine - Flexible search engine
- Full text search in image and PDF - Full text search in image and PDF
- SHA-256 encryption - 256-bit AES encryption
- Tag system - Tag system
- Multi-users ACL system - Multi-users ACL system
- Audit log - Audit log
- Document sharing by URL - Document sharing by URL
- RESTful Web API - RESTful Web API
- Fully featured Android client - Fully featured Android client
- Tested to 100k documents
Download Download
-------- --------

View File

@ -12,10 +12,12 @@
<option name="SELECTED_TEST_ARTIFACT" value="_android_test_" /> <option name="SELECTED_TEST_ARTIFACT" value="_android_test_" />
<option name="ASSEMBLE_TASK_NAME" value="assembleDebug" /> <option name="ASSEMBLE_TASK_NAME" value="assembleDebug" />
<option name="COMPILE_JAVA_TASK_NAME" value="compileDebugSources" /> <option name="COMPILE_JAVA_TASK_NAME" value="compileDebugSources" />
<option name="SOURCE_GEN_TASK_NAME" value="generateDebugSources" />
<option name="ASSEMBLE_TEST_TASK_NAME" value="assembleDebugAndroidTest" /> <option name="ASSEMBLE_TEST_TASK_NAME" value="assembleDebugAndroidTest" />
<option name="COMPILE_JAVA_TEST_TASK_NAME" value="compileDebugAndroidTestSources" /> <option name="COMPILE_JAVA_TEST_TASK_NAME" value="compileDebugAndroidTestSources" />
<option name="TEST_SOURCE_GEN_TASK_NAME" value="generateDebugAndroidTestSources" /> <afterSyncTasks>
<task>generateDebugAndroidTestSources</task>
<task>generateDebugSources</task>
</afterSyncTasks>
<option name="ALLOW_USER_CONFIGURATION" value="false" /> <option name="ALLOW_USER_CONFIGURATION" value="false" />
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/src/main/AndroidManifest.xml" /> <option name="MANIFEST_FILE_RELATIVE_PATH" value="/src/main/AndroidManifest.xml" />
<option name="RES_FOLDER_RELATIVE_PATH" value="/src/main/res" /> <option name="RES_FOLDER_RELATIVE_PATH" value="/src/main/res" />
@ -24,7 +26,7 @@
</configuration> </configuration>
</facet> </facet>
</component> </component>
<component name="NewModuleRootManager" inherit-compiler-output="false"> <component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_1_7" inherit-compiler-output="false">
<output url="file://$MODULE_DIR$/build/intermediates/classes/debug" /> <output url="file://$MODULE_DIR$/build/intermediates/classes/debug" />
<output-test url="file://$MODULE_DIR$/build/intermediates/classes/androidTest/debug" /> <output-test url="file://$MODULE_DIR$/build/intermediates/classes/androidTest/debug" />
<exclude-output /> <exclude-output />
@ -34,13 +36,13 @@
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/debug" isTestSource="false" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/debug" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/debug" isTestSource="false" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/debug" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/debug" type="java-resource" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/debug" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/generated/debug" type="java-resource" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/res/resValues/debug" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/androidTest/debug" isTestSource="true" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/source/r/androidTest/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/androidTest/debug" isTestSource="true" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/source/aidl/androidTest/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/androidTest/debug" isTestSource="true" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/source/buildConfig/androidTest/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/androidTest/debug" isTestSource="true" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/source/rs/androidTest/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/androidTest/debug" type="java-test-resource" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/res/rs/androidTest/debug" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/build/generated/res/generated/androidTest/debug" type="java-test-resource" /> <sourceFolder url="file://$MODULE_DIR$/build/generated/res/resValues/androidTest/debug" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/res" type="java-resource" /> <sourceFolder url="file://$MODULE_DIR$/src/debug/res" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/resources" type="java-resource" /> <sourceFolder url="file://$MODULE_DIR$/src/debug/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/assets" type="java-resource" /> <sourceFolder url="file://$MODULE_DIR$/src/debug/assets" type="java-resource" />
@ -75,9 +77,9 @@
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/dependency-cache" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dependency-cache" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex-cache" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex-cache" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.android.support/appcompat-v7/22.1.1/jars" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.android.support/appcompat-v7/22.2.1/jars" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.android.support/recyclerview-v7/22.0.0/jars" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.android.support/recyclerview-v7/22.2.1/jars" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.android.support/support-v4/22.1.1/jars" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.android.support/support-v4/22.2.1/jars" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.shamanland/fab/0.0.6/jars" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/com.shamanland/fab/0.0.6/jars" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/it.sephiroth.android.library.easing/android-easing/1.0.3/jars" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/it.sephiroth.android.library.easing/android-easing/1.0.3/jars" />
<excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/it.sephiroth.android.library.imagezoom/imagezoom/1.0.5/jars" /> <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/it.sephiroth.android.library.imagezoom/imagezoom/1.0.5/jars" />
@ -106,16 +108,16 @@
</content> </content>
<orderEntry type="jdk" jdkName="Android API 22 Platform" jdkType="Android SDK" /> <orderEntry type="jdk" jdkName="Android API 22 Platform" jdkType="Android SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" exported="" name="appcompat-v7-22.1.1" level="project" />
<orderEntry type="library" exported="" name="fab-0.0.6" level="project" /> <orderEntry type="library" exported="" name="fab-0.0.6" level="project" />
<orderEntry type="library" exported="" name="android-easing-1.0.3" level="project" /> <orderEntry type="library" exported="" name="android-easing-1.0.3" level="project" />
<orderEntry type="library" exported="" name="imagezoom-1.0.5" level="project" /> <orderEntry type="library" exported="" name="imagezoom-1.0.5" level="project" />
<orderEntry type="library" exported="" name="support-v4-22.2.1" level="project" />
<orderEntry type="library" exported="" name="eventbus-2.4.0" level="project" /> <orderEntry type="library" exported="" name="eventbus-2.4.0" level="project" />
<orderEntry type="library" exported="" name="recyclerview-v7-22.2.1" level="project" />
<orderEntry type="library" exported="" name="android-query.0.26.8" level="project" /> <orderEntry type="library" exported="" name="android-query.0.26.8" level="project" />
<orderEntry type="library" exported="" name="tokenautocomplete-1.2.1" level="project" /> <orderEntry type="library" exported="" name="tokenautocomplete-1.2.1" level="project" />
<orderEntry type="library" exported="" name="support-v4-22.1.1" level="project" /> <orderEntry type="library" exported="" name="support-annotations-22.2.1" level="project" />
<orderEntry type="library" exported="" name="support-annotations-22.1.1" level="project" /> <orderEntry type="library" exported="" name="appcompat-v7-22.2.1" level="project" />
<orderEntry type="library" exported="" name="recyclerview-v7-22.0.0" level="project" />
<orderEntry type="library" exported="" name="android-async-http-1.4.6" level="project" /> <orderEntry type="library" exported="" name="android-async-http-1.4.6" level="project" />
</component> </component>
</module> </module>

View File

@ -3,7 +3,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:1.2.3' classpath 'com.android.tools.build:gradle:1.3.0'
} }
} }
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
@ -14,7 +14,7 @@ repositories {
android { android {
compileSdkVersion 22 compileSdkVersion 22
buildToolsVersion "22.0.1" buildToolsVersion "23.0.2"
defaultConfig { defaultConfig {
minSdkVersion 14 minSdkVersion 14
@ -50,8 +50,8 @@ android {
dependencies { dependencies {
compile fileTree(dir: 'libs', include: '*.jar') compile fileTree(dir: 'libs', include: '*.jar')
compile 'com.android.support:appcompat-v7:22.1.1' compile 'com.android.support:appcompat-v7:22.+'
compile 'com.android.support:recyclerview-v7:22.0.0' compile 'com.android.support:recyclerview-v7:22.+'
compile 'com.loopj.android:android-async-http:1.4.6' compile 'com.loopj.android:android-async-http:1.4.6'
compile 'it.sephiroth.android.library.imagezoom:imagezoom:1.0.5' compile 'it.sephiroth.android.library.imagezoom:imagezoom:1.0.5'
compile 'de.greenrobot:eventbus:2.4.0' compile 'de.greenrobot:eventbus:2.4.0'

View File

@ -168,7 +168,7 @@ public class DocumentViewActivity extends AppCompatActivity {
createdDateTextView.setText(date); createdDateTextView.setText(date);
TextView descriptionTextView = (TextView) findViewById(R.id.descriptionTextView); TextView descriptionTextView = (TextView) findViewById(R.id.descriptionTextView);
if (description == null || description.isEmpty()) { if (description == null || description.isEmpty() || description.equals(JSONObject.NULL.toString())) {
descriptionTextView.setVisibility(View.GONE); descriptionTextView.setVisibility(View.GONE);
} else { } else {
descriptionTextView.setVisibility(View.VISIBLE); descriptionTextView.setVisibility(View.VISIBLE);

View File

@ -11,7 +11,7 @@ public class RecyclerItemClickListener implements RecyclerView.OnItemTouchListen
private OnItemClickListener mListener; private OnItemClickListener mListener;
public interface OnItemClickListener { public interface OnItemClickListener {
public void onItemClick(View view, int position); void onItemClick(View view, int position);
} }
GestureDetector mGestureDetector; GestureDetector mGestureDetector;
@ -25,7 +25,8 @@ public class RecyclerItemClickListener implements RecyclerView.OnItemTouchListen
}); });
} }
@Override public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) { @Override
public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) {
View childView = view.findChildViewUnder(e.getX(), e.getY()); View childView = view.findChildViewUnder(e.getX(), e.getY());
if (childView != null && mListener != null && mGestureDetector.onTouchEvent(e)) { if (childView != null && mListener != null && mGestureDetector.onTouchEvent(e)) {
mListener.onItemClick(childView, view.getChildPosition(childView)); mListener.onItemClick(childView, view.getChildPosition(childView));
@ -33,5 +34,9 @@ public class RecyclerItemClickListener implements RecyclerView.OnItemTouchListen
return false; return false;
} }
@Override public void onTouchEvent(RecyclerView view, MotionEvent motionEvent) { } @Override
public void onTouchEvent(RecyclerView view, MotionEvent motionEvent) { }
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { }
} }

View File

@ -113,6 +113,11 @@
<artifactId>bcprov-jdk15on</artifactId> <artifactId>bcprov-jdk15on</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.levigo.jbig2</groupId>
<artifactId>levigo-jbig2-imageio</artifactId>
</dependency>
<!-- OCR dependencies --> <!-- OCR dependencies -->
<dependency> <dependency>
<groupId>jna</groupId> <groupId>jna</groupId>

View File

@ -103,7 +103,7 @@ public class TagDao {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public List<TagDto> getByDocumentId(String documentId, String userId) { public List<TagDto> getByDocumentId(String documentId, String userId) {
EntityManager em = ThreadLocalContext.get().getEntityManager(); EntityManager em = ThreadLocalContext.get().getEntityManager();
StringBuilder sb = new StringBuilder("select t.TAG_ID_C, t.TAG_NAME_C, t.TAG_COLOR_C from T_DOCUMENT_TAG dt "); StringBuilder sb = new StringBuilder("select t.TAG_ID_C, t.TAG_NAME_C, t.TAG_COLOR_C, t.TAG_IDPARENT_C from T_DOCUMENT_TAG dt ");
sb.append(" join T_TAG t on t.TAG_ID_C = dt.DOT_IDTAG_C "); sb.append(" join T_TAG t on t.TAG_ID_C = dt.DOT_IDTAG_C ");
sb.append(" where dt.DOT_IDDOCUMENT_C = :documentId and t.TAG_DELETEDATE_D is null "); sb.append(" where dt.DOT_IDDOCUMENT_C = :documentId and t.TAG_DELETEDATE_D is null ");
sb.append(" and t.TAG_IDUSER_C = :userId and dt.DOT_DELETEDATE_D is null "); sb.append(" and t.TAG_IDUSER_C = :userId and dt.DOT_DELETEDATE_D is null ");
@ -123,6 +123,7 @@ public class TagDao {
tagDto.setId((String) o[i++]); tagDto.setId((String) o[i++]);
tagDto.setName((String) o[i++]); tagDto.setName((String) o[i++]);
tagDto.setColor((String) o[i++]); tagDto.setColor((String) o[i++]);
tagDto.setParentId((String) o[i++]);
tagDtoList.add(tagDto); tagDtoList.add(tagDto);
} }
return tagDtoList; return tagDtoList;
@ -137,7 +138,7 @@ public class TagDao {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public List<TagStatDto> getStats(String userId) { public List<TagStatDto> getStats(String userId) {
EntityManager em = ThreadLocalContext.get().getEntityManager(); EntityManager em = ThreadLocalContext.get().getEntityManager();
StringBuilder sb = new StringBuilder("select t.TAG_ID_C, t.TAG_NAME_C, t.TAG_COLOR_C, count(d.DOC_ID_C) "); StringBuilder sb = new StringBuilder("select t.TAG_ID_C, t.TAG_NAME_C, t.TAG_COLOR_C, t.TAG_IDPARENT_C, count(d.DOC_ID_C) ");
sb.append(" from T_TAG t "); sb.append(" from T_TAG t ");
sb.append(" left join T_DOCUMENT_TAG dt on t.TAG_ID_C = dt.DOT_IDTAG_C and dt.DOT_DELETEDATE_D is null "); sb.append(" left join T_DOCUMENT_TAG dt on t.TAG_ID_C = dt.DOT_IDTAG_C and dt.DOT_DELETEDATE_D is null ");
sb.append(" left join T_DOCUMENT d on d.DOC_ID_C = dt.DOT_IDDOCUMENT_C and d.DOC_DELETEDATE_D is null and d.DOC_IDUSER_C = :userId "); sb.append(" left join T_DOCUMENT d on d.DOC_ID_C = dt.DOT_IDDOCUMENT_C and d.DOC_DELETEDATE_D is null and d.DOC_IDUSER_C = :userId ");
@ -158,6 +159,7 @@ public class TagDao {
tagDto.setId((String) o[i++]); tagDto.setId((String) o[i++]);
tagDto.setName((String) o[i++]); tagDto.setName((String) o[i++]);
tagDto.setColor((String) o[i++]); tagDto.setColor((String) o[i++]);
tagDto.setParentId((String) o[i++]);
tagDto.setCount(((Number) o[i++]).intValue()); tagDto.setCount(((Number) o[i++]).intValue());
tagStatDtoList.add(tagDto); tagStatDtoList.add(tagDto);
} }
@ -281,6 +283,7 @@ public class TagDao {
// Update the tag // Update the tag
tagFromDb.setName(tag.getName()); tagFromDb.setName(tag.getName());
tagFromDb.setColor(tag.getColor()); tagFromDb.setColor(tag.getColor());
tagFromDb.setParentId(tag.getParentId());
// Create audit log // Create audit log
AuditLogUtil.create(tagFromDb, AuditLogType.UPDATE); AuditLogUtil.create(tagFromDb, AuditLogType.UPDATE);

View File

@ -24,6 +24,11 @@ public class TagDto {
*/ */
private String color; private String color;
/**
* Parent ID.
*/
private String parentId;
/** /**
* Getter of id. * Getter of id.
* *
@ -77,4 +82,22 @@ public class TagDto {
public void setColor(String color) { public void setColor(String color) {
this.color = color; this.color = color;
} }
/**
* Getter of parentId.
*
* @return the parentId
*/
public String getParentId() {
return parentId;
}
/**
* Setter of parentId.
*
* @param color parentId
*/
public void setParentId(String parentId) {
this.parentId = parentId;
}
} }

View File

@ -2,7 +2,7 @@ package com.sismics.docs.core.dao.jpa.dto;
/** /**
* Tag DTO. * Tag stat DTO.
* *
* @author bgamard * @author bgamard
*/ */

View File

@ -39,6 +39,12 @@ public class Tag implements Loggable {
@Column(name = "TAG_IDUSER_C", nullable = false, length = 36) @Column(name = "TAG_IDUSER_C", nullable = false, length = 36)
private String userId; private String userId;
/**
* User ID.
*/
@Column(name = "TAG_IDPARENT_C", length = 36)
private String parentId;
/** /**
* Creation date. * Creation date.
*/ */
@ -166,11 +172,30 @@ public class Tag implements Loggable {
this.deleteDate = deleteDate; this.deleteDate = deleteDate;
} }
/**
* Getter of parentId.
*
* @return parentId
*/
public String getParentId() {
return parentId;
}
/**
* Setter of parentId.
*
* @param parentId parentId
*/
public void setParentId(String parentId) {
this.parentId = parentId;
}
@Override @Override
public String toString() { public String toString() {
return MoreObjects.toStringHelper(this) return MoreObjects.toStringHelper(this)
.add("id", id) .add("id", id)
.add("name", name) .add("name", name)
.add("parentId", parentId)
.toString(); .toString();
} }

View File

@ -105,7 +105,7 @@ public class FileUtil {
PDDocument pdfDocument = null; PDDocument pdfDocument = null;
try { try {
PDFTextStripper stripper = new PDFTextStripper(); PDFTextStripper stripper = new PDFTextStripper();
pdfDocument = PDDocument.load(inputStream, true); pdfDocument = PDDocument.load(inputStream);
content = stripper.getText(pdfDocument); content = stripper.getText(pdfDocument);
} catch (IOException e) { } catch (IOException e) {
log.error("Error while extracting text from the PDF", e); log.error("Error while extracting text from the PDF", e);
@ -157,7 +157,7 @@ public class FileUtil {
// Generate preview from the first page of the PDF // Generate preview from the first page of the PDF
PDDocument pdfDocument = null; PDDocument pdfDocument = null;
try { try {
pdfDocument = PDDocument.load(inputStream, true); pdfDocument = PDDocument.load(inputStream);
PDFRenderer renderer = new PDFRenderer(pdfDocument); PDFRenderer renderer = new PDFRenderer(pdfDocument);
image = renderer.renderImage(0); image = renderer.renderImage(0);
} finally { } finally {

View File

@ -1 +1 @@
db.version=1 db.version=2

View File

@ -0,0 +1,2 @@
alter table T_TAG add column TAG_IDPARENT_C varchar(36);
update T_CONFIG set CFG_VALUE_C = '2' where CFG_ID_C = 'DB_VERSION';

View File

@ -35,6 +35,7 @@
<joda-time.joda-time.version>2.8.2</joda-time.joda-time.version> <joda-time.joda-time.version>2.8.2</joda-time.joda-time.version>
<org.hibernate.hibernate.version>4.1.0.Final</org.hibernate.hibernate.version> <org.hibernate.hibernate.version>4.1.0.Final</org.hibernate.hibernate.version>
<javax.servlet.javax.servlet-api.version>3.1.0</javax.servlet.javax.servlet-api.version> <javax.servlet.javax.servlet-api.version>3.1.0</javax.servlet.javax.servlet-api.version>
<com.levigo.jbig2.levigo-jbig2-imageio.version>1.6.3</com.levigo.jbig2.levigo-jbig2-imageio.version>
<org.eclipse.jetty.jetty-server.version>9.2.13.v20150730</org.eclipse.jetty.jetty-server.version> <org.eclipse.jetty.jetty-server.version>9.2.13.v20150730</org.eclipse.jetty.jetty-server.version>
<org.eclipse.jetty.jetty-webapp.version>9.2.13.v20150730</org.eclipse.jetty.jetty-webapp.version> <org.eclipse.jetty.jetty-webapp.version>9.2.13.v20150730</org.eclipse.jetty.jetty-webapp.version>
@ -66,6 +67,12 @@
<enabled>true</enabled> <enabled>true</enabled>
</snapshots> </snapshots>
</repository> </repository>
<repository>
<id>jbig2.googlecode</id>
<name>JBIG2 ImageIO-Plugin repository at googlecode.com</name>
<url>http://jbig2-imageio.googlecode.com/svn/maven-repository</url>
</repository>
</repositories> </repositories>
@ -252,11 +259,11 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.glassfish.jersey.test-framework.providers</groupId> <groupId>org.glassfish.jersey.test-framework.providers</groupId>
<artifactId>jersey-test-framework-provider-bundle</artifactId> <artifactId>jersey-test-framework-provider-bundle</artifactId>
<type>pom</type> <type>pom</type>
<version>${org.glassfish.jersey.version}</version> <version>${org.glassfish.jersey.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.glassfish.jersey.test-framework.providers</groupId> <groupId>org.glassfish.jersey.test-framework.providers</groupId>
@ -295,10 +302,10 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.hibernate</groupId> <groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId> <artifactId>hibernate-validator</artifactId>
<version>${org.hibernate.hibernate.version}</version> <version>${org.hibernate.hibernate.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>commons-dbcp</groupId> <groupId>commons-dbcp</groupId>
@ -354,6 +361,13 @@
<version>${org.bouncycastle.bcprov-jdk15on.version}</version> <version>${org.bouncycastle.bcprov-jdk15on.version}</version>
</dependency> </dependency>
<!-- Used to read JBIG2 images. See https://github.com/sismics/docs/issues/38 -->
<dependency>
<groupId>com.levigo.jbig2</groupId>
<artifactId>levigo-jbig2-imageio</artifactId>
<version>${com.levigo.jbig2.levigo-jbig2-imageio.version}</version>
</dependency>
<!-- OCR dependencies --> <!-- OCR dependencies -->
<dependency> <dependency>
<groupId>jna</groupId> <groupId>jna</groupId>

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=1 db.version=2

View File

@ -19,6 +19,7 @@ import com.sismics.docs.core.util.jpa.PaginatedLists;
import com.sismics.docs.core.util.jpa.SortCriteria; import com.sismics.docs.core.util.jpa.SortCriteria;
import com.sismics.rest.exception.ForbiddenClientException; import com.sismics.rest.exception.ForbiddenClientException;
import com.sismics.rest.exception.ServerException; import com.sismics.rest.exception.ServerException;
import com.sismics.rest.util.JsonUtil;
/** /**
* Audit log REST resources. * Audit log REST resources.
@ -70,7 +71,7 @@ public class AuditLogResource extends BaseResource {
.add("target", auditLogDto.getEntityId()) .add("target", auditLogDto.getEntityId())
.add("class", auditLogDto.getEntityClass()) .add("class", auditLogDto.getEntityClass())
.add("type", auditLogDto.getType().name()) .add("type", auditLogDto.getType().name())
.add("message", auditLogDto.getMessage()) .add("message", JsonUtil.nullable(auditLogDto.getMessage()))
.add("create_date", auditLogDto.getCreateTimestamp())); .add("create_date", auditLogDto.getCreateTimestamp()));
} }

View File

@ -22,6 +22,7 @@ import com.sismics.docs.core.dao.jpa.dto.TagStatDto;
import com.sismics.docs.core.model.jpa.Tag; import com.sismics.docs.core.model.jpa.Tag;
import com.sismics.rest.exception.ClientException; import com.sismics.rest.exception.ClientException;
import com.sismics.rest.exception.ForbiddenClientException; import com.sismics.rest.exception.ForbiddenClientException;
import com.sismics.rest.util.JsonUtil;
import com.sismics.rest.util.ValidationUtil; import com.sismics.rest.util.ValidationUtil;
/** /**
@ -50,7 +51,8 @@ public class TagResource extends BaseResource {
items.add(Json.createObjectBuilder() items.add(Json.createObjectBuilder()
.add("id", tag.getId()) .add("id", tag.getId())
.add("name", tag.getName()) .add("name", tag.getName())
.add("color", tag.getColor())); .add("color", tag.getColor())
.add("parent", JsonUtil.nullable(tag.getParentId())));
} }
JsonObjectBuilder response = Json.createObjectBuilder() JsonObjectBuilder response = Json.createObjectBuilder()
@ -96,7 +98,8 @@ public class TagResource extends BaseResource {
@PUT @PUT
public Response add( public Response add(
@FormParam("name") String name, @FormParam("name") String name,
@FormParam("color") String color) { @FormParam("color") String color,
@FormParam("parent") String parentId) {
if (!authenticate()) { if (!authenticate()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -117,11 +120,22 @@ public class TagResource extends BaseResource {
throw new ClientException("AlreadyExistingTag", MessageFormat.format("Tag already exists: {0}", name)); throw new ClientException("AlreadyExistingTag", MessageFormat.format("Tag already exists: {0}", name));
} }
// Check the parent
if (StringUtils.isEmpty(parentId)) {
parentId = null;
} else {
Tag parentTag = tagDao.getByTagId(principal.getId(), parentId);
if (parentTag == null) {
throw new ClientException("ParentNotFound", MessageFormat.format("Parent not found: {0}", parentId));
}
}
// Create the tag // Create the tag
tag = new Tag(); tag = new Tag();
tag.setName(name); tag.setName(name);
tag.setColor(color); tag.setColor(color);
tag.setUserId(principal.getId()); tag.setUserId(principal.getId());
tag.setParentId(parentId);
String id = tagDao.create(tag); String id = tagDao.create(tag);
JsonObjectBuilder response = Json.createObjectBuilder() JsonObjectBuilder response = Json.createObjectBuilder()
@ -140,7 +154,8 @@ public class TagResource extends BaseResource {
public Response update( public Response update(
@PathParam("id") String id, @PathParam("id") String id,
@FormParam("name") String name, @FormParam("name") String name,
@FormParam("color") String color) { @FormParam("color") String color,
@FormParam("parent") String parentId) {
if (!authenticate()) { if (!authenticate()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -161,6 +176,16 @@ public class TagResource extends BaseResource {
throw new ClientException("TagNotFound", MessageFormat.format("Tag not found: {0}", id)); throw new ClientException("TagNotFound", MessageFormat.format("Tag not found: {0}", id));
} }
// Check the parent
if (StringUtils.isEmpty(parentId)) {
parentId = null;
} else {
Tag parentTag = tagDao.getByTagId(principal.getId(), parentId);
if (parentTag == null) {
throw new ClientException("ParentNotFound", MessageFormat.format("Parent not found: {0}", parentId));
}
}
// Check for name duplicate // Check for name duplicate
Tag tagDuplicate = tagDao.getByName(principal.getId(), name); Tag tagDuplicate = tagDao.getByName(principal.getId(), name);
if (tagDuplicate != null && !tagDuplicate.getId().equals(id)) { if (tagDuplicate != null && !tagDuplicate.getId().equals(id)) {
@ -174,6 +199,8 @@ public class TagResource extends BaseResource {
if (!StringUtils.isEmpty(color)) { if (!StringUtils.isEmpty(color)) {
tag.setColor(color); tag.setColor(color);
} }
// Parent tag is always updated to have the possibility to delete it
tag.setParentId(parentId);
tagDao.update(tag); tagDao.update(tag);

View File

@ -40,6 +40,7 @@ import com.sismics.docs.rest.constant.BaseFunction;
import com.sismics.rest.exception.ClientException; import com.sismics.rest.exception.ClientException;
import com.sismics.rest.exception.ForbiddenClientException; import com.sismics.rest.exception.ForbiddenClientException;
import com.sismics.rest.exception.ServerException; import com.sismics.rest.exception.ServerException;
import com.sismics.rest.util.JsonUtil;
import com.sismics.rest.util.ValidationUtil; import com.sismics.rest.util.ValidationUtil;
import com.sismics.security.UserPrincipal; import com.sismics.security.UserPrincipal;
import com.sismics.util.filter.TokenBasedSecurityFilter; import com.sismics.util.filter.TokenBasedSecurityFilter;
@ -513,8 +514,8 @@ public class UserResource extends BaseResource {
for (AuthenticationToken authenticationToken : authenticationTokenDao.getByUserId(principal.getId())) { for (AuthenticationToken authenticationToken : authenticationTokenDao.getByUserId(principal.getId())) {
JsonObjectBuilder session = Json.createObjectBuilder() JsonObjectBuilder session = Json.createObjectBuilder()
.add("create_date", authenticationToken.getCreationDate().getTime()) .add("create_date", authenticationToken.getCreationDate().getTime())
.add("ip", authenticationToken.getIp()) .add("ip", JsonUtil.nullable(authenticationToken.getIp()))
.add("user_agent", authenticationToken.getUserAgent()); .add("user_agent", JsonUtil.nullable(authenticationToken.getUserAgent()));
if (authenticationToken.getLastConnectionDate() != null) { if (authenticationToken.getLastConnectionDate() != null) {
session.add("last_connection_date", authenticationToken.getLastConnectionDate().getTime()); session.add("last_connection_date", authenticationToken.getLastConnectionDate().getTime());
} }

View File

@ -125,6 +125,9 @@ angular.module('docs',
} }
} }
}) })
.state('document.default.search', {
url: '/search/:search'
})
.state('document.default.file', { .state('document.default.file', {
url: '/file/:fileId', url: '/file/:fileId',
views: { views: {
@ -153,6 +156,7 @@ angular.module('docs',
}) })
.state('document.view', { .state('document.view', {
url: '/view/:id', url: '/view/:id',
redirectTo: 'document.view.content',
views: { views: {
'document': { 'document': {
templateUrl: 'partial/docs/document.view.html', templateUrl: 'partial/docs/document.view.html',
@ -160,6 +164,33 @@ angular.module('docs',
} }
} }
}) })
.state('document.view.content', {
url: '/content',
views: {
'tab': {
templateUrl: 'partial/docs/document.view.content.html',
controller: 'DocumentViewContent'
}
}
})
.state('document.view.permissions', {
url: '/permissions',
views: {
'tab': {
templateUrl: 'partial/docs/document.view.permissions.html',
controller: 'DocumentViewPermissions'
}
}
})
.state('document.view.activity', {
url: '/activity',
views: {
'tab': {
templateUrl: 'partial/docs/document.view.activity.html',
controller: 'DocumentViewActivity'
}
}
})
.state('document.view.file', { .state('document.view.file', {
url: '/file/:fileId', url: '/file/:fileId',
views: { views: {
@ -228,4 +259,18 @@ angular.module('docs',
$rootScope.$state = $state; $rootScope.$state = $state;
$rootScope.$stateParams = $stateParams; $rootScope.$stateParams = $stateParams;
$rootScope.pageTitle = 'Sismics Docs'; $rootScope.pageTitle = 'Sismics Docs';
})
/**
* Redirection support for ui-router.
* Thanks to https://github.com/acollard
* See https://github.com/angular-ui/ui-router/issues/1584#issuecomment-76993045
*/
.run(function($rootScope, $state){
$rootScope.$on('$stateChangeStart', function(event, toState, toParams) {
var redirect = toState.redirectTo;
if (redirect) {
event.preventDefault();
$state.go(redirect, toParams);
}
});
}); });

View File

@ -12,7 +12,8 @@ angular.module('docs').controller('Document', function($scope, $timeout, $state,
$scope.offset = 0; $scope.offset = 0;
$scope.currentPage = 1; $scope.currentPage = 1;
$scope.limit = _.isUndefined(localStorage.documentsPageSize) ? 10 : localStorage.documentsPageSize; $scope.limit = _.isUndefined(localStorage.documentsPageSize) ? 10 : localStorage.documentsPageSize;
$scope.search = ''; $scope.search = $state.params.search ? $state.params.search : '';
$scope.setSearch = function(search) { $scope.search = search };
// A timeout promise is used to slow down search requests to the server // A timeout promise is used to slow down search requests to the server
// We keep track of it for cancellation purpose // We keep track of it for cancellation purpose
@ -65,6 +66,17 @@ angular.module('docs').controller('Document', function($scope, $timeout, $state,
$timeout.cancel(timeoutPromise); $timeout.cancel(timeoutPromise);
} }
if ($state.current.name == 'document.default'
|| $state.current.name == 'document.default.search') {
$state.go($scope.search == '' ?
'document.default' : 'document.default.search', {
search: $scope.search
}, {
location: 'replace',
notify: false
});
}
// Call API later // Call API later
timeoutPromise = $timeout(function () { timeoutPromise = $timeout(function () {
$scope.loadDocuments(); $scope.loadDocuments();
@ -99,6 +111,22 @@ angular.module('docs').controller('Document', function($scope, $timeout, $state,
* Display a document. * Display a document.
*/ */
$scope.viewDocument = function(id) { $scope.viewDocument = function(id) {
$state.transitionTo('document.view', { id: id }); $state.go('document.view', { id: id });
};
// Load tags
var tags = [];
Restangular.one('tag/list').getList().then(function(data) {
tags = data.tags;
});
/**
* Find children tags.
* @param parent
*/
$scope.getChildrenTags = function(parent) {
return _.filter(tags, function(tag) {
return tag.parent == parent;
});
}; };
}); });

View File

@ -81,7 +81,7 @@ angular.module('docs').controller('DocumentDefault', function($scope, $state, Re
* Navigate to the selected file. * Navigate to the selected file.
*/ */
$scope.openFile = function (file) { $scope.openFile = function (file) {
$state.transitionTo('document.default.file', { fileId: file.id }) $state.go('document.default.file', { fileId: file.id })
}; };
/** /**
@ -107,6 +107,6 @@ angular.module('docs').controller('DocumentDefault', function($scope, $state, Re
* Add a document with checked files. * Add a document with checked files.
*/ */
$scope.addDocument = function() { $scope.addDocument = function() {
$state.transitionTo('document.add', { files: _.pluck($scope.checkedFiles(), 'id') }); $state.go('document.add', { files: _.pluck($scope.checkedFiles(), 'id') });
}; };
}); });

View File

@ -95,7 +95,7 @@ angular.module('docs').controller('DocumentEdit', function($rootScope, $scope, $
if ($scope.isEdit()) { if ($scope.isEdit()) {
// Go back to the edited document // Go back to the edited document
$scope.pageDocuments(); $scope.pageDocuments();
$state.transitionTo('document.view', { id: $stateParams.id }); $state.go('document.view', { id: $stateParams.id });
} else { } else {
// Reset the scope and stay here // Reset the scope and stay here
var fileUploadCount = _.size($scope.newFiles) + resolve.length; var fileUploadCount = _.size($scope.newFiles) + resolve.length;
@ -188,9 +188,9 @@ angular.module('docs').controller('DocumentEdit', function($rootScope, $scope, $
*/ */
$scope.cancel = function() { $scope.cancel = function() {
if ($scope.isEdit()) { if ($scope.isEdit()) {
$state.transitionTo('document.view', { id: $stateParams.id }); $state.go('document.view', { id: $stateParams.id });
} else { } else {
$state.transitionTo('document.default'); $state.go('document.default');
} }
}; };

View File

@ -3,7 +3,7 @@
/** /**
* Document view controller. * Document view controller.
*/ */
angular.module('docs').controller('DocumentView', function ($scope, $state, $stateParams, $location, $dialog, $modal, Restangular, $upload, $q) { angular.module('docs').controller('DocumentView', function ($scope, $state, $stateParams, $location, $dialog, $modal, Restangular, $timeout) {
// Load document data from server // Load document data from server
Restangular.one('document', $stateParams.id).get().then(function(data) { Restangular.one('document', $stateParams.id).get().then(function(data) {
$scope.document = data; $scope.document = data;
@ -11,60 +11,6 @@ angular.module('docs').controller('DocumentView', function ($scope, $state, $sta
$scope.error = response; $scope.error = response;
}); });
// Load audit log data from server
Restangular.one('auditlog').get({
document: $stateParams.id
}).then(function(data) {
$scope.logs = data.logs;
});
// Watch for ACLs change and group them for easy displaying
$scope.$watch('document.acls', function(acls) {
$scope.acls = _.groupBy(acls, function(acl) {
return acl.id;
});
});
// Initialize add ACL
$scope.acl = { perm: 'READ' };
/**
* Configuration for file sorting.
*/
$scope.fileSortableOptions = {
forceHelperSize: true,
forcePlaceholderSize: true,
tolerance: 'pointer',
handle: '.handle',
stop: function () {
// Send new positions to server
$scope.$apply(function () {
Restangular.one('file').post('reorder', {
id: $stateParams.id,
order: _.pluck($scope.files, 'id')
});
});
}
};
/**
* Load files from server.
*/
$scope.loadFiles = function () {
Restangular.one('file').getList('list', { id: $stateParams.id }).then(function (data) {
$scope.files = data.files;
// TODO Keep currently uploading files
});
};
$scope.loadFiles();
/**
* Navigate to the selected file.
*/
$scope.openFile = function (file) {
$state.transitionTo('document.view.file', { id: $stateParams.id, fileId: file.id })
};
/** /**
* Delete a document. * Delete a document.
*/ */
@ -80,27 +26,7 @@ angular.module('docs').controller('DocumentView', function ($scope, $state, $sta
if (result == 'ok') { if (result == 'ok') {
Restangular.one('document', document.id).remove().then(function () { Restangular.one('document', document.id).remove().then(function () {
$scope.loadDocuments(); $scope.loadDocuments();
$state.transitionTo('document.default'); $state.go('document.default');
});
}
});
};
/**
* Delete a file.
*/
$scope.deleteFile = function (file) {
var title = 'Delete file';
var msg = 'Do you really want to delete this file?';
var btns = [
{result: 'cancel', label: 'Cancel'},
{result: 'ok', label: 'OK', cssClass: 'btn-primary'}
];
$dialog.messageBox(title, msg, btns, function (result) {
if (result == 'ok') {
Restangular.one('file', file.id).remove().then(function () {
$scope.loadFiles();
}); });
} }
}); });
@ -157,125 +83,4 @@ angular.module('docs').controller('DocumentView', function ($scope, $state, $sta
} }
}); });
}; };
/**
* File has been drag & dropped.
*/
$scope.fileDropped = function(files) {
if (!$scope.document.writable) {
return;
}
if (files && files.length) {
// Adding files to the UI
var newfiles = [];
_.each(files, function(file) {
var newfile = {
progress: 0,
name: file.name,
create_date: new Date().getTime(),
mimetype: file.type,
status: 'Pending...'
};
$scope.files.push(newfile);
newfiles.push(newfile);
});
// Uploading files sequentially
var key = 0;
var then = function() {
if (files[key]) {
$scope.uploadFile(files[key], newfiles[key++]).then(then);
}
};
then();
}
};
/**
* Upload a file.
*/
$scope.uploadFile = function(file, newfile) {
// Upload the file
newfile.status = 'Uploading...';
return $upload.upload({
method: 'PUT',
url: '../api/file',
file: file,
fields: {
id: $stateParams.id
}
})
.progress(function (e) {
newfile.progress = parseInt(100.0 * e.loaded / e.total);
})
.success(function (data) {
newfile.id = data.id;
});
};
/**
* Delete an ACL.
*/
$scope.deleteAcl = function(acl) {
Restangular.one('acl/' + $stateParams.id + '/' + acl.perm + '/' + acl.id, null).remove().then(function () {
$scope.document.acls = _.reject($scope.document.acls, function(s) {
return angular.equals(acl, s);
});
});
};
/**
* Add an ACL.
*/
$scope.addAcl = function() {
// Compute ACLs to add
$scope.acl.source = $stateParams.id;
var acls = [];
if ($scope.acl.perm == 'READWRITE') {
acls = [{
source: $stateParams.id,
username: $scope.acl.username,
perm: 'READ'
}, {
source: $stateParams.id,
username: $scope.acl.username,
perm: 'WRITE'
}];
} else {
acls = [{
source: $stateParams.id,
username: $scope.acl.username,
perm: $scope.acl.perm
}];
}
// Add ACLs
_.each(acls, function(acl) {
Restangular.one('acl').put(acl).then(function(acl) {
if (_.isUndefined(acl.id)) {
return;
}
$scope.document.acls.push(acl);
$scope.document.acls = angular.copy($scope.document.acls);
});
});
// Reset form
$scope.acl = { perm: 'READ' };
};
/**
* Auto-complete on ACL target.
*/
$scope.getTargetAclTypeahead = function($viewValue) {
var deferred = $q.defer();
Restangular.one('acl/target/search')
.get({
search: $viewValue
}).then(function(data) {
deferred.resolve(_.pluck(data.users, 'username'), true);
});
return deferred.promise;
};
}); });

View File

@ -0,0 +1,13 @@
'use strict';
/**
* Document view activity controller.
*/
angular.module('docs').controller('DocumentViewActivity', function ($scope, $stateParams, Restangular) {
// Load audit log data from server
Restangular.one('auditlog').get({
document: $stateParams.id
}).then(function(data) {
$scope.logs = data.logs;
});
});

View File

@ -0,0 +1,119 @@
'use strict';
/**
* Document view content controller.
*/
angular.module('docs').controller('DocumentViewContent', function ($scope, $stateParams, Restangular, $dialog, $state, $upload) {
/**
* Configuration for file sorting.
*/
$scope.fileSortableOptions = {
forceHelperSize: true,
forcePlaceholderSize: true,
tolerance: 'pointer',
handle: '.handle',
stop: function () {
// Send new positions to server
$scope.$apply(function () {
Restangular.one('file').post('reorder', {
id: $stateParams.id,
order: _.pluck($scope.files, 'id')
});
});
}
};
/**
* Load files from server.
*/
$scope.loadFiles = function () {
Restangular.one('file').getList('list', { id: $stateParams.id }).then(function (data) {
$scope.files = data.files;
// TODO Keep currently uploading files
});
};
$scope.loadFiles();
/**
* Navigate to the selected file.
*/
$scope.openFile = function (file) {
$state.go('document.view.file', { id: $stateParams.id, fileId: file.id })
};
/**
* Delete a file.
*/
$scope.deleteFile = function (file) {
var title = 'Delete file';
var msg = 'Do you really want to delete this file?';
var btns = [
{result: 'cancel', label: 'Cancel'},
{result: 'ok', label: 'OK', cssClass: 'btn-primary'}
];
$dialog.messageBox(title, msg, btns, function (result) {
if (result == 'ok') {
Restangular.one('file', file.id).remove().then(function () {
$scope.loadFiles();
});
}
});
};
/**
* File has been drag & dropped.
*/
$scope.fileDropped = function(files) {
if (!$scope.document.writable) {
return;
}
if (files && files.length) {
// Adding files to the UI
var newfiles = [];
_.each(files, function(file) {
var newfile = {
progress: 0,
name: file.name,
create_date: new Date().getTime(),
mimetype: file.type,
status: 'Pending...'
};
$scope.files.push(newfile);
newfiles.push(newfile);
});
// Uploading files sequentially
var key = 0;
var then = function() {
if (files[key]) {
$scope.uploadFile(files[key], newfiles[key++]).then(then);
}
};
then();
}
};
/**
* Upload a file.
*/
$scope.uploadFile = function(file, newfile) {
// Upload the file
newfile.status = 'Uploading...';
return $upload.upload({
method: 'PUT',
url: '../api/file',
file: file,
fields: {
id: $stateParams.id
}
})
.progress(function (e) {
newfile.progress = parseInt(100.0 * e.loaded / e.total);
})
.success(function (data) {
newfile.id = data.id;
});
};
});

View File

@ -0,0 +1,81 @@
'use strict';
/**
* Document view permissions controller.
*/
angular.module('docs').controller('DocumentViewPermissions', function ($scope, $stateParams, Restangular, $q) {
// Watch for ACLs change and group them for easy displaying
$scope.$watch('document.acls', function(acls) {
$scope.acls = _.groupBy(acls, function(acl) {
return acl.id;
});
});
// Initialize add ACL
$scope.acl = { perm: 'READ' };
/**
* Delete an ACL.
*/
$scope.deleteAcl = function(acl) {
Restangular.one('acl/' + $stateParams.id + '/' + acl.perm + '/' + acl.id, null).remove().then(function () {
$scope.document.acls = _.reject($scope.document.acls, function(s) {
return angular.equals(acl, s);
});
});
};
/**
* Add an ACL.
*/
$scope.addAcl = function() {
// Compute ACLs to add
$scope.acl.source = $stateParams.id;
var acls = [];
if ($scope.acl.perm == 'READWRITE') {
acls = [{
source: $stateParams.id,
username: $scope.acl.username,
perm: 'READ'
}, {
source: $stateParams.id,
username: $scope.acl.username,
perm: 'WRITE'
}];
} else {
acls = [{
source: $stateParams.id,
username: $scope.acl.username,
perm: $scope.acl.perm
}];
}
// Add ACLs
_.each(acls, function(acl) {
Restangular.one('acl').put(acl).then(function(acl) {
if (_.isUndefined(acl.id)) {
return;
}
$scope.document.acls.push(acl);
$scope.document.acls = angular.copy($scope.document.acls);
});
});
// Reset form
$scope.acl = { perm: 'READ' };
};
/**
* Auto-complete on ACL target.
*/
$scope.getTargetAclTypeahead = function($viewValue) {
var deferred = $q.defer();
Restangular.one('acl/target/search')
.get({
search: $viewValue
}).then(function(data) {
deferred.resolve(_.pluck(data.users, 'username'), true);
});
return deferred.promise;
};
});

View File

@ -9,7 +9,7 @@ angular.module('docs').controller('Login', function($scope, $rootScope, $state,
User.userInfo(true).then(function(data) { User.userInfo(true).then(function(data) {
$rootScope.userInfo = data; $rootScope.userInfo = data;
}); });
$state.transitionTo('document.default'); $state.go('document.default');
}, function() { }, function() {
var title = 'Login failed'; var title = 'Login failed';
var msg = 'Username or password invalid'; var msg = 'Username or password invalid';

View File

@ -6,9 +6,13 @@
angular.module('docs').controller('Main', function($scope, $rootScope, $state, User) { angular.module('docs').controller('Main', function($scope, $rootScope, $state, User) {
User.userInfo().then(function(data) { User.userInfo().then(function(data) {
if (data.anonymous) { if (data.anonymous) {
$state.transitionTo('login'); $state.go('login', {}, {
location: 'replace'
});
} else { } else {
$state.transitionTo('document.default'); $state.go('document.default', {}, {
location: 'replace'
});
} }
}); });
}); });

View File

@ -41,7 +41,7 @@ angular.module('docs').controller('Navigation', function($scope, $http, $state,
*/ */
$scope.openLogs = function() { $scope.openLogs = function() {
$scope.errorNumber = 0; $scope.errorNumber = 0;
$state.transitionTo('settings.log'); $state.go('settings.log');
}; };
/** /**
@ -52,7 +52,7 @@ angular.module('docs').controller('Navigation', function($scope, $http, $state,
User.userInfo(true).then(function(data) { User.userInfo(true).then(function(data) {
$rootScope.userInfo = data; $rootScope.userInfo = data;
}); });
$state.transitionTo('main'); $state.go('main');
}); });
$event.preventDefault(); $event.preventDefault();
}; };

View File

@ -19,6 +19,6 @@ angular.module('docs').controller('SettingsUser', function($scope, $state, Resta
* Edit a user. * Edit a user.
*/ */
$scope.editUser = function(user) { $scope.editUser = function(user) {
$state.transitionTo('settings.user.edit', { username: user.username }); $state.go('settings.user.edit', { username: user.username });
}; };
}); });

View File

@ -38,7 +38,7 @@ angular.module('docs').controller('SettingsUserEdit', function($scope, $dialog,
promise.then(function() { promise.then(function() {
$scope.loadUsers(); $scope.loadUsers();
$state.transitionTo('settings.user'); $state.go('settings.user');
}); });
}; };
@ -54,9 +54,9 @@ angular.module('docs').controller('SettingsUserEdit', function($scope, $dialog,
if (result == 'ok') { if (result == 'ok') {
Restangular.one('user', $stateParams.username).remove().then(function() { Restangular.one('user', $stateParams.username).remove().then(function() {
$scope.loadUsers(); $scope.loadUsers();
$state.transitionTo('settings.user'); $state.go('settings.user');
}, function () { }, function () {
$state.transitionTo('settings.user'); $state.go('settings.user');
}); });
} }
}); });

View File

@ -24,7 +24,7 @@ angular.module('share').controller('FileModalView', function($rootScope, $modalI
if (value.id == $stateParams.fileId) { if (value.id == $stateParams.fileId) {
var next = $scope.files[key + 1]; var next = $scope.files[key + 1];
if (next) { if (next) {
$state.transitionTo('share.file', { documentId: $stateParams.documentId, shareId: $stateParams.shareId, fileId: next.id }); $state.go('share.file', { documentId: $stateParams.documentId, shareId: $stateParams.shareId, fileId: next.id });
} }
} }
}); });
@ -38,7 +38,7 @@ angular.module('share').controller('FileModalView', function($rootScope, $modalI
if (value.id == $stateParams.fileId) { if (value.id == $stateParams.fileId) {
var previous = $scope.files[key - 1]; var previous = $scope.files[key - 1];
if (previous) { if (previous) {
$state.transitionTo('share.file', { documentId: $stateParams.documentId, shareId: $stateParams.shareId, fileId: previous.id }); $state.go('share.file', { documentId: $stateParams.documentId, shareId: $stateParams.shareId, fileId: previous.id });
} }
} }
}); });

View File

@ -16,6 +16,6 @@ angular.module('share').controller('FileView', function($modal, $state, $statePa
modal.closed = true; modal.closed = true;
},function(result) { },function(result) {
modal.closed = true; modal.closed = true;
$state.transitionTo('share', { documentId: $stateParams.documentId, shareId: $stateParams.shareId }); $state.go('share', { documentId: $stateParams.documentId, shareId: $stateParams.shareId });
}); });
}); });

View File

@ -10,7 +10,7 @@ angular.module('share').controller('Share', function($scope, $state, $stateParam
$scope.document = data; $scope.document = data;
}, function (response) { }, function (response) {
if (response.status == 403) { if (response.status == 403) {
$state.transitionTo('403'); $state.go('403');
} }
}); });
@ -24,6 +24,6 @@ angular.module('share').controller('Share', function($scope, $state, $stateParam
* Navigate to the selected file. * Navigate to the selected file.
*/ */
$scope.openFile = function (file) { $scope.openFile = function (file) {
$state.transitionTo('share.file', { documentId: $stateParams.documentId, shareId: $stateParams.shareId, fileId: file.id }) $state.go('share.file', { documentId: $stateParams.documentId, shareId: $stateParams.shareId, fileId: file.id })
}; };
}); });

View File

@ -43,6 +43,9 @@
<script src="app/docs/controller/DocumentDefault.js" type="text/javascript"></script> <script src="app/docs/controller/DocumentDefault.js" type="text/javascript"></script>
<script src="app/docs/controller/DocumentEdit.js" type="text/javascript"></script> <script src="app/docs/controller/DocumentEdit.js" type="text/javascript"></script>
<script src="app/docs/controller/DocumentView.js" type="text/javascript"></script> <script src="app/docs/controller/DocumentView.js" type="text/javascript"></script>
<script src="app/docs/controller/DocumentViewContent.js" type="text/javascript"></script>
<script src="app/docs/controller/DocumentViewPermissions.js" type="text/javascript"></script>
<script src="app/docs/controller/DocumentViewActivity.js" type="text/javascript"></script>
<script src="app/docs/controller/DocumentModalShare.js" type="text/javascript"></script> <script src="app/docs/controller/DocumentModalShare.js" type="text/javascript"></script>
<script src="app/docs/controller/FileView.js" type="text/javascript"></script> <script src="app/docs/controller/FileView.js" type="text/javascript"></script>
<script src="app/docs/controller/FileModalView.js" type="text/javascript"></script> <script src="app/docs/controller/FileModalView.js" type="text/javascript"></script>

View File

@ -5,49 +5,61 @@
<a href="#/document/add" class="btn btn-primary"><span class="glyphicon glyphicon-plus"></span> Add a document</a> <a href="#/document/add" class="btn btn-primary"><span class="glyphicon glyphicon-plus"></span> Add a document</a>
</p> </p>
<div class="input-group"> <div class="row">
<span class="input-group-addon"> <div class="dropdown col-md-2 tag-tree-dropdown" dropdown>
<span class="glyphicon glyphicon glyphicon-info-sign" <button class="btn btn-block btn-default" dropdown-toggle ng-disabled="disabled">
tooltip-placement="bottom" Tags <span class="caret"></span>
tooltip-html-unsafe="before:2012-05<br/> </button>
after:2012-05<br/> <ul class="dropdown-menu tag-tree">
at:2012-05<br/> <li ng-if="getChildrenTags().length == 0">No tags</li>
tag:car<br/> <li ng-repeat="tag in getChildrenTags()" ng-include="'tag-tree-item'"></li>
full:led<br/> </ul>
shared:yes<br/> </div>
lang:fra"></span>
</span> <div class="col-md-10 input-group">
<input type="search" class="form-control" placeholder="Search" ng-model="search" /> <span class="input-group-addon">
<span class="input-group-addon"> <span class="glyphicon glyphicon glyphicon-info-sign"
<span class="glyphicon glyphicon-search" ng-show="search.length == 0"></span> tooltip-placement="bottom"
<span class="glyphicon glyphicon-remove" ng-show="search.length > 0" ng-click="search = ''"></span> tooltip-html-unsafe="before:2012-05<br/>
</span> after:2012-05<br/>
at:2012-05<br/>
tag:car<br/>
full:led<br/>
shared:yes<br/>
lang:fra"></span>
</span>
<input type="search" class="form-control" placeholder="Search" ng-model="search" />
<span class="input-group-addon">
<span class="glyphicon glyphicon-search" ng-show="search.length == 0"></span>
<span class="glyphicon glyphicon-remove" ng-show="search.length > 0" ng-click="search = ''"></span>
</span>
</div>
</div> </div>
<table class="row table table-striped table-hover table-documents"> <table class="row table table-striped table-hover table-documents">
<thead> <thead>
<tr> <tr>
<th class="col-xs-6" ng-click="sortDocuments(1)"><span class="glyphicon glyphicon-chevron-{{ sortColumn == 1 ? (asc ? 'down' : 'up') : '' }}"></span> Title</th> <th class="col-xs-6" ng-click="sortDocuments(1)"><span class="glyphicon glyphicon-chevron-{{ sortColumn == 1 ? (asc ? 'down' : 'up') : '' }}"></span> Title</th>
<th class="col-xs-3" ng-click="sortDocuments(3)"><span class="glyphicon glyphicon-chevron-{{ sortColumn == 3 ? (asc ? 'down' : 'up') : '' }}"></span> Creation date</th> <th class="col-xs-3" ng-click="sortDocuments(3)"><span class="glyphicon glyphicon-chevron-{{ sortColumn == 3 ? (asc ? 'down' : 'up') : '' }}"></span> Creation date</th>
<th class="col-xs-3 hidden-xs">Tags</th> <th class="col-xs-3 hidden-xs">Tags</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr ng-click="viewDocument(document.id)" ng-repeat="document in documents"> <tr ng-click="viewDocument(document.id)" ng-repeat="document in documents">
<td> <td>
{{ document.title }} ({{ document.file_count }}) {{ document.title }} ({{ document.file_count }})
<span class="glyphicon glyphicon-share" ng-if="document.shared" tooltip="Shared"></span> <span class="glyphicon glyphicon-share" ng-if="document.shared" tooltip="Shared"></span>
<a href="#/document/view/{{ document.id }}" ng-click="$event.stopPropagation()"><span class="glyphicon glyphicon-link"></span></a> <a href="#/document/view/{{ document.id }}" target="_blank" ng-click="$event.stopPropagation()"><span class="glyphicon glyphicon-link"></span></a>
</td> </td>
<td>{{ document.create_date | date: 'yyyy-MM-dd' }}</td> <td>{{ document.create_date | date: 'yyyy-MM-dd' }}</td>
<td class="hidden-xs cell-tags"> <td class="hidden-xs cell-tags">
<div class="tags"> <div class="tags">
<span class="label label-info" ng-repeat="tag in document.tags" ng-style="{ 'background': tag.color }"> <span class="label label-info" ng-repeat="tag in document.tags" ng-style="{ 'background': tag.color }">
<span class="shorten">{{ tag.name | shorten }}</span><span class="full">{{ tag.name }}</span> <span class="shorten">{{ tag.name | shorten }}</span><span class="full">{{ tag.name }}</span>
</span> </span>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -71,3 +83,13 @@
<div ui-view="document"></div> <div ui-view="document"></div>
</div> </div>
</div> </div>
<script type="text/ng-template" id="tag-tree-item">
<span class="btn" ng-style="{ 'background-color': tag.color }"></span>
<span class="btn btn-link" ng-click="setSearch('tag:' + tag.name)">
{{ tag.name }}
</span>
<ul class="list-unstyled">
<li ng-repeat="tag in getChildrenTags(tag.id)" ng-include="'tag-tree-item'"></li>
</ul>
</script>

View File

@ -0,0 +1 @@
<audit-log logs="logs" />

View File

@ -0,0 +1,37 @@
<p ng-bind-html="document.description | newline"></p>
<div ng-file-drop drag-over-class="bg-success" ng-multiple="true" allow-dir="false" ng-model="dropFiles"
accept="image/*,application/pdf,application/zip" ng-file-change="fileDropped($files, $event, $rejectedFiles)">
<div class="row upload-zone" ui-sortable="fileSortableOptions" ng-model="files">
<div class="col-xs-6 col-sm-4 col-md-3 col-lg-2 text-center" ng-repeat="file in files">
<div class="thumbnail" ng-if="file.id">
<a ng-click="openFile(file)">
<img class="thumbnail-file" ng-src="../api/file/{{ file.id }}/data?size=thumb" tooltip="{{ file.mimetype }}" tooltip-placement="top" />
</a>
<div class="caption" ng-show="document.writable">
<div class="pull-left">
<div class="btn btn-default handle"><span class="glyphicon glyphicon-resize-horizontal"></span></div>
</div>
<div class="pull-right">
<button class="btn btn-danger" ng-click="deleteFile(file)"><span class="glyphicon glyphicon-trash"></span></button>
</div>
<div class="clearfix"></div>
</div>
</div>
<div class="thumbnail" ng-if="!file.id">
<p class="text-center lead">
{{ file.status }}
</p>
<div class="caption">
<progressbar value="file.progress" class="progress-info active"></progressbar>
</div>
</div>
</div>
<p class="text-center well-lg" ng-if="files.length == 0">
<span class="glyphicon glyphicon-move"></span>
Drag &amp; drop files here to upload
</p>
</div>
</div>

View File

@ -38,119 +38,24 @@
</ul> </ul>
</div> </div>
<tabset> <ul class="nav nav-tabs">
<tab> <li ng-class="{ active: $state.current.name == 'document.view.content' }">
<tab-heading class="pointer"> <a href="#/document/view/{{ document.id }}/content">
<span class="glyphicon glyphicon-file"></span> Content <span class="glyphicon glyphicon-file"></span> Content
</tab-heading> </a>
</li>
<p ng-bind-html="document.description | newline"></p> <li ng-class="{ active: $state.current.name == 'document.view.permissions' }">
<a href="#/document/view/{{ document.id }}/permissions">
<div ng-file-drop drag-over-class="bg-success" ng-multiple="true" allow-dir="false" ng-model="dropFiles"
accept="image/*,application/pdf,application/zip" ng-file-change="fileDropped($files, $event, $rejectedFiles)">
<div class="row upload-zone" ui-sortable="fileSortableOptions" ng-model="files">
<div class="col-xs-6 col-sm-4 col-md-3 col-lg-2 text-center" ng-repeat="file in files">
<div class="thumbnail" ng-if="file.id">
<a ng-click="openFile(file)">
<img class="thumbnail-file" ng-src="../api/file/{{ file.id }}/data?size=thumb" tooltip="{{ file.mimetype }}" tooltip-placement="top" />
</a>
<div class="caption" ng-show="document.writable">
<div class="pull-left">
<div class="btn btn-default handle"><span class="glyphicon glyphicon-resize-horizontal"></span></div>
</div>
<div class="pull-right">
<button class="btn btn-danger" ng-click="deleteFile(file)"><span class="glyphicon glyphicon-trash"></span></button>
</div>
<div class="clearfix"></div>
</div>
</div>
<div class="thumbnail" ng-if="!file.id">
<p class="text-center lead">
{{ file.status }}
</p>
<div class="caption">
<progressbar value="file.progress" class="progress-info active"></progressbar>
</div>
</div>
</div>
<p class="text-center well-lg" ng-if="files.length == 0">
<span class="glyphicon glyphicon-move"></span>
Drag &amp; drop files here to upload
</p>
</div>
</div>
</tab>
<tab>
<tab-heading class="pointer">
<span class="glyphicon glyphicon-user"></span> Permissions <span class="glyphicon glyphicon-user"></span> Permissions
</tab-heading> </a>
</li>
<table class="table"> <li ng-class="{ active: $state.current.name == 'document.view.activity' }">
<tr> <a href="#/document/view/{{ document.id }}/activity">
<th style="width: 40%">For</th>
<th style="width: 40%">Permission</th>
</tr>
<tr ng-repeat="(id, acl) in acls">
<td><em>{{ acl[0].type == 'SHARE' ? 'Shared' : 'User' }}</em> {{ acl[0].name }}</td>
<td>
<span class="label label-default" style="margin-right: 6px;" ng-repeat="a in acl | orderBy: 'perm'">
{{ a.perm }}
<span ng-show="(document.creator != a.name && a.type == 'USER' || a.type != 'USER') && document.writable"
class="glyphicon glyphicon-remove pointer"
ng-click="deleteAcl(a)"></span>
</span>
</td>
</tr>
</table>
<div ng-show="document.writable">
<h4>Add a permission</h4>
<form name="aclForm" class="form-horizontal">
<div class="form-group">
<label class=" col-sm-2 control-label" for="inputTarget">User</label>
<div class="col-sm-3">
<input required ng-maxlength="50" class="form-control" type="text" id="inputTarget"
placeholder="Type a username" name="username" ng-model="acl.username" autocomplete="off"
typeahead="username for username in getTargetAclTypeahead($viewValue) | filter: $viewValue"
typeahead-wait-ms="200" />
</div>
</div>
<div class="form-group">
<label class=" col-sm-2 control-label" for="inputPermission">Permission</label>
<div class="col-sm-3">
<select class="form-control" ng-model="acl.perm" id="inputPermission">
<option value="READ">Can read</option>
<option value="READWRITE">Can edit</option>
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" ng-disabled="!aclForm.$valid" ng-click="addAcl()">
<span class="glyphicon glyphicon-plus"></span>
Add
</button>
</div>
</div>
</form>
</div>
</tab>
<tab>
<tab-heading class="pointer">
<span class="glyphicon glyphicon-tasks"></span> Activity <span class="glyphicon glyphicon-tasks"></span> Activity
</tab-heading> </a>
</li>
<audit-log logs="logs" /> </ul>
</tab>
</tabset>
<div ui-view="tab"></div>
<div ui-view="file"></div> <div ui-view="file"></div>
</div> </div>

View File

@ -0,0 +1,53 @@
<table class="table">
<tr>
<th style="width: 40%">For</th>
<th style="width: 40%">Permission</th>
</tr>
<tr ng-repeat="(id, acl) in acls">
<td><em>{{ acl[0].type == 'SHARE' ? 'Shared' : 'User' }}</em> {{ acl[0].name }}</td>
<td>
<span class="label label-default" style="margin-right: 6px;" ng-repeat="a in acl | orderBy: 'perm'">
{{ a.perm }}
<span ng-show="(document.creator != a.name && a.type == 'USER' || a.type != 'USER') && document.writable"
class="glyphicon glyphicon-remove pointer"
ng-click="deleteAcl(a)"></span>
</span>
</td>
</tr>
</table>
<div ng-show="document.writable">
<h4>Add a permission</h4>
<form name="aclForm" class="form-horizontal">
<div class="form-group">
<label class=" col-sm-2 control-label" for="inputTarget">User</label>
<div class="col-sm-3">
<input required ng-maxlength="50" class="form-control" type="text" id="inputTarget"
placeholder="Type a username" name="username" ng-model="acl.username" autocomplete="off"
typeahead="username for username in getTargetAclTypeahead($viewValue) | filter: $viewValue"
typeahead-wait-ms="200" />
</div>
</div>
<div class="form-group">
<label class=" col-sm-2 control-label" for="inputPermission">Permission</label>
<div class="col-sm-3">
<select class="form-control" ng-model="acl.perm" id="inputPermission">
<option value="READ">Can read</option>
<option value="READWRITE">Can edit</option>
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" ng-disabled="!aclForm.$valid" ng-click="addAcl()">
<span class="glyphicon glyphicon-plus"></span>
Add
</button>
</div>
</div>
</form>
</div>

View File

@ -21,6 +21,15 @@
<tbody> <tbody>
<tr ng-repeat="tag in tags | filter:search"> <tr ng-repeat="tag in tags | filter:search">
<td><inline-edit value="tag.name" on-edit="updateTag(tag)" /></td> <td><inline-edit value="tag.name" on-edit="updateTag(tag)" /></td>
<td class="col-xs-4">
<select class="form-control" ng-model="tag.parent" ng-change="updateTag(tag)">
<option value="" ng-selected="!tag.parent"></option>
<option ng-repeat="tag0 in tags"
ng-if="tag0.id != tag.id"
ng-selected="tag.parent == tag0.id"
value="{{ tag0.id }}">Parent: {{ tag0.name }}</option>
</select>
</td>
<td class="col-xs-1"><span colorpicker class="btn" on-hide="updateTag(tag)" data-color="" ng-model="tag.color" ng-style="{ 'background': tag.color }">&nbsp;</span></td> <td class="col-xs-1"><span colorpicker class="btn" on-hide="updateTag(tag)" data-color="" ng-model="tag.color" ng-style="{ 'background': tag.color }">&nbsp;</span></td>
<td class="col-xs-1"><button class="btn btn-danger pull-right" ng-click="deleteTag(tag)"><span class="glyphicon glyphicon-trash"></span></button></td> <td class="col-xs-1"><button class="btn btn-danger pull-right" ng-click="deleteTag(tag)"><span class="glyphicon glyphicon-trash"></span></button></td>
</tr> </tr>

View File

@ -184,3 +184,16 @@ input[readonly].share-link {
.tab-pane { .tab-pane {
margin-top: 20px; margin-top: 20px;
} }
// Tag tree
.tag-tree-dropdown {
padding-left: 0;
.tag-tree {
li {
margin-left: 20px;
margin-top: 8px;
margin-bottom: 8px;
}
}
}

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=1 db.version=2

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=1 db.version=2

View File

@ -2,6 +2,7 @@ package com.sismics.docs.rest;
import javax.json.JsonArray; import javax.json.JsonArray;
import javax.json.JsonObject; import javax.json.JsonObject;
import javax.json.JsonValue;
import javax.ws.rs.client.Entity; import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Form; import javax.ws.rs.core.Form;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@ -44,7 +45,8 @@ public class TestTagResource extends BaseJerseyTest {
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, tag1Token) .cookie(TokenBasedSecurityFilter.COOKIE_NAME, tag1Token)
.put(Entity.form(new Form() .put(Entity.form(new Form()
.param("name", "Tag4") .param("name", "Tag4")
.param("color", "#00ff00")), JsonObject.class); .param("color", "#00ff00")
.param("parent", tag3Id)), JsonObject.class);
String tag4Id = json.getString("id"); String tag4Id = json.getString("id");
Assert.assertNotNull(tag4Id); Assert.assertNotNull(tag4Id);
@ -129,6 +131,7 @@ public class TestTagResource extends BaseJerseyTest {
Assert.assertTrue(tags.size() > 0); Assert.assertTrue(tags.size() > 0);
Assert.assertEquals("Tag4", tags.getJsonObject(1).getString("name")); Assert.assertEquals("Tag4", tags.getJsonObject(1).getString("name"));
Assert.assertEquals("#00ff00", tags.getJsonObject(1).getString("color")); Assert.assertEquals("#00ff00", tags.getJsonObject(1).getString("color"));
Assert.assertEquals(tag3Id, tags.getJsonObject(1).getString("parent"));
// Update a tag // Update a tag
json = target().path("/tag/" + tag4Id).request() json = target().path("/tag/" + tag4Id).request()
@ -146,6 +149,7 @@ public class TestTagResource extends BaseJerseyTest {
Assert.assertTrue(tags.size() > 0); Assert.assertTrue(tags.size() > 0);
Assert.assertEquals("UpdatedName", tags.getJsonObject(1).getString("name")); Assert.assertEquals("UpdatedName", tags.getJsonObject(1).getString("name"));
Assert.assertEquals("#0000ff", tags.getJsonObject(1).getString("color")); Assert.assertEquals("#0000ff", tags.getJsonObject(1).getString("color"));
Assert.assertEquals(JsonValue.NULL, tags.getJsonObject(1).get("parent"));
// Deletes a tag // Deletes a tag
target().path("/tag/" + tag4Id).request() target().path("/tag/" + tag4Id).request()