diff --git a/howdju-common/lib/apiModels.ts b/howdju-common/lib/apiModels.ts index be67470f..ac6e0d2e 100644 --- a/howdju-common/lib/apiModels.ts +++ b/howdju-common/lib/apiModels.ts @@ -127,6 +127,7 @@ export const ExternalJustificationSearchFilters = [ "writId", // Justifications based on this PropositionCompound "propositionCompoundId", + "mediaExcerptId", "sourceExcerptParaphraseId", // Justifications based on this proposition in a PropositionCompound "propositionId", @@ -144,3 +145,5 @@ export interface SortDescription { } export type PersorgOut = Persisted; + +export type TagOut = Persisted; diff --git a/howdju-common/lib/contextTrails.ts b/howdju-common/lib/contextTrails.ts index 4fb6ee5a..449318c1 100644 --- a/howdju-common/lib/contextTrails.ts +++ b/howdju-common/lib/contextTrails.ts @@ -185,8 +185,9 @@ export function areValidTargetAndConnectingEntity( prev.entity.basis.entity.atoms, (a) => a.entity.id === id ); - case "SOURCE_EXCERPT": case "WRIT_QUOTE": + // TODO(20): when we add Appearances, connect them to MediaExcerpts here. + case "MEDIA_EXCERPT": return false; } } diff --git a/howdju-common/lib/enums.ts b/howdju-common/lib/enums.ts index e3717131..62ee0e26 100644 --- a/howdju-common/lib/enums.ts +++ b/howdju-common/lib/enums.ts @@ -65,6 +65,7 @@ export const JustificationBasisSourceTypes = { PROPOSITION: "PROPOSITION", /** @deprecated TODO(215) */ SOURCE_EXCERPT_PARAPHRASE: "SOURCE_EXCERPT_PARAPHRASE", + MEDIA_EXCERPT: "MEDIA_EXCERPT", } as const; export type JustificationBasisSourceType = typeof JustificationBasisSourceTypes[keyof typeof JustificationBasisSourceTypes]; diff --git a/howdju-common/lib/models.ts b/howdju-common/lib/models.ts index cdf81581..b9e34eef 100644 --- a/howdju-common/lib/models.ts +++ b/howdju-common/lib/models.ts @@ -90,6 +90,13 @@ export const hasQuote = (j: Justification) => export const isPropositionCompoundBased = ( j: Justification | CreateJustification | CreateJustificationInput ) => (j ? j.basis.type === "PROPOSITION_COMPOUND" : false); + +export function isMediaExcerptBased( + j: Justification | CreateJustification | CreateJustificationInput +) { + return j.basis.type === "MEDIA_EXCERPT"; +} + export const isWritQuoteBased = ( j: Justification | CreateJustification | CreateJustificationInput ) => (j ? j.basis.type === "WRIT_QUOTE" : false); @@ -483,6 +490,11 @@ const muxCreateJustificationBasisErrors = ( _errors: errors._errors, propositionCompound: errors.entity, }; + case "MEDIA_EXCERPT": + return { + _errors: errors._errors, + mediaExcerpt: errors.entity, + }; case "WRIT_QUOTE": return { _errors: errors._errors, @@ -533,6 +545,14 @@ const demuxCreateJustificationInputBasis = ( type: "PROPOSITION_COMPOUND", entity: basis.propositionCompound, }; + case "MEDIA_EXCERPT": + if (!basis.mediaExcerpt) { + throw newImpossibleError("Media excerpt must be defined."); + } + return { + type: "MEDIA_EXCERPT", + entity: basis.mediaExcerpt, + }; case "WRIT_QUOTE": // TODO(201) WritQuote bases are temporarily supported until we support SourceExcerpt bases. return { diff --git a/howdju-common/lib/viewModels.ts b/howdju-common/lib/viewModels.ts index a910e784..d8c709ff 100644 --- a/howdju-common/lib/viewModels.ts +++ b/howdju-common/lib/viewModels.ts @@ -1,9 +1,16 @@ -import { JustificationOut, PropositionOut, StatementOut } from "./apiModels"; +import { + JustificationOut, + MediaExcerptOut, + PropositionCompoundOut, + PropositionOut, + StatementOut, + WritQuoteOut, +} from "./apiModels"; /** A JustificationOut that has been joined with its root target in the client */ export type JustificationView = Omit< JustificationOut, - "rootTarget" | "rootTargetType" | "target" + "rootTarget" | "rootTargetType" | "target" | "basis" > & ( | { @@ -28,4 +35,35 @@ export type JustificationView = Omit< type: "JUSTIFICATION"; entity: JustificationView; }; + } & { + basis: + | { + type: "PROPOSITION_COMPOUND"; + entity: PropositionCompoundOut; + } + | { + type: "MEDIA_EXCERPT"; + entity: MediaExcerptView; + } + | { + type: "WRIT_QUOTE"; + entity: WritQuoteOut; + }; + }; + +export interface MediaExcerptView extends MediaExcerptOut { + citations: (MediaExcerptOut["citations"][number] & { + /** A key uniquely identifying a citation relative to others. */ + key: string; + })[]; + locators: MediaExcerptOut["locators"] & { + urlLocators: (MediaExcerptOut["locators"]["urlLocators"][number] & { + /** A key uniquely identifying a url locator relative to others. */ + key: string; + })[]; }; + speakers: (MediaExcerptOut["speakers"][number] & { + /** A key uniquely identifying a persorg relative to others. */ + key: string; + })[]; +} diff --git a/howdju-common/lib/zodSchemaTypes.ts b/howdju-common/lib/zodSchemaTypes.ts index 0ce24973..871aab6c 100644 --- a/howdju-common/lib/zodSchemaTypes.ts +++ b/howdju-common/lib/zodSchemaTypes.ts @@ -90,7 +90,10 @@ export type PersistedJustificationWithRootRef = Omit< type: "PROPOSITION_COMPOUND"; entity: Persisted; } - | { type: "SOURCE_EXCERPT"; entity: Persisted } + | { + type: "MEDIA_EXCERPT"; + entity: Persisted; + } | { type: "WRIT_QUOTE"; entity: Persisted }; }; @@ -121,6 +124,10 @@ export type BasedJustificationWithRootRef = Omit< type: "PROPOSITION_COMPOUND"; entity: Persisted; } + | { + type: "MEDIA_EXCERPT"; + entity: Persisted; + } | { type: "SOURCE_EXCERPT"; entity: Persisted } | { type: "WRIT_QUOTE"; entity: Persisted }; }; diff --git a/howdju-common/lib/zodSchemas.ts b/howdju-common/lib/zodSchemas.ts index 5113aa81..a9049309 100644 --- a/howdju-common/lib/zodSchemas.ts +++ b/howdju-common/lib/zodSchemas.ts @@ -473,7 +473,25 @@ export type CreateMediaExcerptCitationInput = z.output< typeof CreateMediaExcerptCitationInput >; -/** A representation of an excerpt of some fixed media conveying speech. */ +/** + * A representation of an excerpt of some fixed media conveying speech. * + * + * Two MediaExcerpts are equivalent if they represent the same 'speech act'. A 'speech act' is a + * single event of a particular speaker saying a particular thing at a particular time. The + * MediaExcerpts are only equivalent if the media represents the same direct speech. For example, + * if two translators translate the same speech into different languages, MediaExcerpts of those + * translations are not necessarily equivalent because the translators may have introduced their own + * interpretation on top of the original speech. + * + * The same speaker saying the same thing at a different time is a different speech act. + * + * Two MediaExcerpts are equivalent if they represent the same speech in the same part of the same + * source. For example, two MediaExcerpts are equivalent if they represent the same speech in the + * same part of the same video, even if they are different resolutions or cropped differently. + * However, two MediaExcerpts are not equivalent if they represent the same speech in different + * parts of the same video. + * + */ export const MediaExcerpt = Entity.extend({ /** * One or more local representations of the excerpt. @@ -486,6 +504,8 @@ export const MediaExcerpt = Entity.extend({ * Text-based: * - focusText: a part of quotation that is the substance of the excerpt, while the rest of * quotation provides additional context. The focusText must appear within the quotation. + * - contextText: text that encompasses the focusText to provie additional context, but which + * does not convey the substance of the excerpt. * - description: a textual description of non-textual content. Like an img alt text. (How do * users provite signal for an inaccurate description? The more literal the localRep, the less * possibility for interpretation.) @@ -504,7 +524,11 @@ export const MediaExcerpt = Entity.extend({ */ localRep: z.object({ /** - * Text or speech that literally appears in the media. + * Text or speech that literally appears in the media and conveys the substance of the speech. + * + * Users may use this field either for focusText or for contextText (as described above.) It's + * an open question how we would migrate this field to a focusText/contextText split if we + * decided to do that. * * For textual media, this text must appear in the media. For audio and video media, this * text must be a transcription of the speech that appears in the media. @@ -659,6 +683,10 @@ export type Justification = Entity & { type: "PROPOSITION_COMPOUND"; entity: PropositionCompound; } + | { + type: "MEDIA_EXCERPT"; + entity: MediaExcerpt; + } /* @deprecated TODO(38) Replace with MediaExcerpt */ | { type: "SOURCE_EXCERPT"; @@ -691,6 +719,7 @@ export type CounterJustification = Justification & { }; export const JustificationBasisType = z.enum([ "PROPOSITION_COMPOUND", + "MEDIA_EXCERPT", "SOURCE_EXCERPT", // deprecated "WRIT_QUOTE", @@ -711,6 +740,10 @@ const justificationBaseShape = { type: z.literal(JustificationBasisTypes.PROPOSITION_COMPOUND), entity: PropositionCompound, }), + z.object({ + type: z.literal("MEDIA_EXCERPT"), + entity: MediaExcerpt, + }), z.object({ type: z.literal(JustificationBasisTypes.SOURCE_EXCERPT), entity: SourceExcerpt, @@ -979,6 +1012,7 @@ export type CreateJustificationInput = Entity & { basis: { type: JustificationBasisType; propositionCompound: EntityOrRef; + mediaExcerpt?: MediaExcerptRef; sourceExcerpt: EntityOrRef; // deprecated writQuote: EntityOrRef; @@ -994,14 +1028,17 @@ export type CreateJustificationInput = Entity & { const createJustificationBaseShape = { ...omit(justificationBaseShape, ["created"]), basis: z.object({ - type: z.enum(["PROPOSITION_COMPOUND", "SOURCE_EXCERPT", "WRIT_QUOTE"] as [ - JustificationBasisType, - ...JustificationBasisType[] - ]), + type: z.enum([ + "PROPOSITION_COMPOUND", + "MEDIA_EXCERPT", + "SOURCE_EXCERPT", + "WRIT_QUOTE", + ] as [JustificationBasisType, ...JustificationBasisType[]]), propositionCompound: z.union([ CreatePropositionCompound, PropositionCompoundRef, ]), + mediaExcerpt: MediaExcerptRef.optional(), sourceExcerpt: z.union([CreateSourceExcerpt, SourceExcerptRef]), writQuote: z.union([CreateWritQuote, WritQuoteRef]), justificationBasisCompound: Entity.optional(), @@ -1010,14 +1047,17 @@ const createJustificationBaseShape = { const createJustificationInputBaseShape = { ...omit(createJustificationBaseShape, ["basis"]), basis: z.object({ - type: z.enum(["PROPOSITION_COMPOUND", "SOURCE_EXCERPT", "WRIT_QUOTE"] as [ - JustificationBasisType, - ...JustificationBasisType[] - ]), + type: z.enum([ + "PROPOSITION_COMPOUND", + "MEDIA_EXCERPT", + "SOURCE_EXCERPT", + "WRIT_QUOTE", + ] as [JustificationBasisType, ...JustificationBasisType[]]), propositionCompound: z.union([ CreatePropositionCompoundInput, PropositionCompoundRef, ]), + mediaExcerpt: MediaExcerptRef.optional(), sourceExcerpt: z.union([CreateSourceExcerptInput, SourceExcerptRef]), writQuote: z.union([CreateWritQuoteInput, WritQuoteRef]), justificationBasisCompound: Entity.optional(), @@ -1073,6 +1113,10 @@ export type CreateJustification = Simplify< type: "PROPOSITION_COMPOUND"; entity: EntityOrRef; } + | { + type: "MEDIA_EXCERPT"; + entity: MediaExcerptRef; + } | { type: "SOURCE_EXCERPT"; entity: EntityOrRef; @@ -1104,6 +1148,10 @@ export const CreateJustification: z.ZodType = z.lazy(() => type: z.literal("PROPOSITION_COMPOUND"), entity: z.union([CreatePropositionCompound, PropositionCompoundRef]), }), + z.object({ + type: z.literal("MEDIA_EXCERPT"), + entity: MediaExcerptRef, + }), z.object({ type: z.literal("SOURCE_EXCERPT"), entity: z.union([CreateSourceExcerpt, SourceExcerptRef]), diff --git a/howdju-service-common/lib/daos/JustificationsDao.ts b/howdju-service-common/lib/daos/JustificationsDao.ts index 58508fc5..e85c0dfe 100644 --- a/howdju-service-common/lib/daos/JustificationsDao.ts +++ b/howdju-service-common/lib/daos/JustificationsDao.ts @@ -7,7 +7,7 @@ import map from "lodash/map"; import mapValues from "lodash/mapValues"; import snakeCase from "lodash/snakeCase"; import { Moment } from "moment"; -import { fromPairs, isArray, sortBy, toString } from "lodash"; +import { difference, fromPairs, isArray, sortBy, toString } from "lodash"; import { assert, @@ -55,6 +55,7 @@ import { } from "./daosUtil"; import { DatabaseSortDirection } from "./daoModels"; import { StatementsDao } from "./StatementsDao"; +import { MediaExcerptsDao } from "./MediaExcerptsDao"; import { PropositionCompoundsDao } from "./PropositionCompoundsDao"; import { WritQuotesDao } from "./WritQuotesDao"; import { JustificationBasisCompoundsDao } from "./JustificationBasisCompoundsDao"; @@ -84,6 +85,7 @@ export class JustificationsDao { database: Database; statementsDao: StatementsDao; propositionCompoundsDao: PropositionCompoundsDao; + mediaExcerptsDao: MediaExcerptsDao; writQuotesDao: WritQuotesDao; justificationBasisCompoundsDao: JustificationBasisCompoundsDao; writQuoteUrlTargetsDao: WritQuoteUrlTargetsDao; @@ -93,6 +95,7 @@ export class JustificationsDao { database: Database, statementsDao: StatementsDao, propositionCompoundsDao: PropositionCompoundsDao, + mediaExcerptsDao: MediaExcerptsDao, writQuotesDao: WritQuotesDao, justificationBasisCompoundsDao: JustificationBasisCompoundsDao, writQuoteUrlTargetsDao: WritQuoteUrlTargetsDao @@ -102,6 +105,7 @@ export class JustificationsDao { database, statementsDao, propositionCompoundsDao, + mediaExcerptsDao, writQuotesDao, justificationBasisCompoundsDao, writQuoteUrlTargetsDao, @@ -110,6 +114,7 @@ export class JustificationsDao { this.database = database; this.statementsDao = statementsDao; this.propositionCompoundsDao = propositionCompoundsDao; + this.mediaExcerptsDao = mediaExcerptsDao; this.writQuotesDao = writQuotesDao; this.justificationBasisCompoundsDao = justificationBasisCompoundsDao; this.writQuoteUrlTargetsDao = writQuoteUrlTargetsDao; @@ -511,6 +516,7 @@ export class JustificationsDao { // TODO(228) add statement targets similarly to how we handle justifications and propositions above // (add targetStatementsById) await this.addStatements(justifications); + await this.addMediaExcerpts(justifications); if (includeUrls) { await this.addUrls(justifications); await this.addUrlTargets(justifications); @@ -601,6 +607,7 @@ export class JustificationsDao { ) ); await this.addStatements(justifications); + await this.addMediaExcerpts(justifications); return justifications; } @@ -877,6 +884,35 @@ export class JustificationsDao { } } + private async addMediaExcerpts(justifications: BasedJustificationDataOut[]) { + const mediaExcerptIds = new Set(); + const justificationsByBasisMediaExcerptId = new Map(); + for (const justification of justifications) { + if (justification.basis.type !== "MEDIA_EXCERPT") { + continue; + } + const mediaExcerptId = justification.basis.entity.id; + justificationsByBasisMediaExcerptId.set(mediaExcerptId, justification); + mediaExcerptIds.add(mediaExcerptId); + } + const mediaExcerpts = await this.mediaExcerptsDao.readMediaExcerptsForIds( + Array.from(mediaExcerptIds) + ); + if (!mediaExcerpts.every(isDefined)) { + const missingMediaExcerptIds = difference( + [...mediaExcerptIds], + mediaExcerpts.filter(isDefined).map((me) => me.id) + ); + throw new EntityNotFoundError("MEDIA_EXCERPT", missingMediaExcerptIds); + } + for (const mediaExcerpt of mediaExcerpts) { + const justification = justificationsByBasisMediaExcerptId.get( + mediaExcerpt.id + ); + justification.basis.entity = mediaExcerpt; + } + } + private async addUrls(justifications: BasedJustificationDataOut[]) { for (const justification of justifications) { if ( @@ -1024,6 +1060,12 @@ export class JustificationsDao { ); break; } + case "mediaExcerptId": { + clauses.push( + makeMediaExcerptJustificationClause(filter, columnNames) + ); + break; + } case "sourceExcerptParaphraseId": { clauses.push( makeSourceExcerptParaphraseJustificationClause(filter, columnNames) @@ -1592,6 +1634,31 @@ function makePropositionCompoundJustificationClause( }; } +function makeMediaExcerptJustificationClause( + mediaExcerptId: EntityId | EntityId[], + justificationColumns: string[] +) { + const select = toSelect(justificationColumns, "j"); + const sql = ` + select + ${select} + from + justifications j + join media_excerpts me on + j.basis_type = $1 + and j.basis_id = me.media_excerpt_id + where + j.deleted is null + and me.deleted is null + and me.media_excerpt_id in ($2) + `; + const args = ["MEDIA_EXCERPT", toInArgString(mediaExcerptId)]; + return { + sql, + args, + }; +} + function makeJustificationIdJustificationClause( justificationId: EntityId | EntityId[], justificationColumns: string[] diff --git a/howdju-service-common/lib/daos/MediaExcerptsDao.ts b/howdju-service-common/lib/daos/MediaExcerptsDao.ts index 181fed51..42d2c00c 100644 --- a/howdju-service-common/lib/daos/MediaExcerptsDao.ts +++ b/howdju-service-common/lib/daos/MediaExcerptsDao.ts @@ -52,6 +52,10 @@ export class MediaExcerptsDao { this.persorgsDao = persorgsDao; } + async readMediaExcerptsForIds(ids: EntityId[]) { + return await Promise.all(ids.map((id) => this.readMediaExcerptForId(id))); + } + async readMediaExcerptForId( mediaExcerptId: EntityId ): Promise { diff --git a/howdju-service-common/lib/daos/orm.ts b/howdju-service-common/lib/daos/orm.ts index 096b2d86..822455b3 100644 --- a/howdju-service-common/lib/daos/orm.ts +++ b/howdju-service-common/lib/daos/orm.ts @@ -18,6 +18,7 @@ import { JustificationRef, JustificationVoteRef, logger, + MediaExcerptRef, newExhaustedEnumError, newImpossibleError, newProgrammingError, @@ -509,6 +510,14 @@ function extractJustificationBasis( return { type, entity }; } + case "MEDIA_EXCERPT": { + // MediaExcerpt is queried separately, not as part of a join. + return { + type, + entity: brandedParse(MediaExcerptRef, { id: toIdString(row.basis_id) }), + }; + } + case "SOURCE_EXCERPT": throw newImpossibleError(`Unsupported JustificationBasisTypes: ${type}`); diff --git a/howdju-service-common/lib/initializers/TestHelper.ts b/howdju-service-common/lib/initializers/TestHelper.ts index 23673a8c..44729076 100644 --- a/howdju-service-common/lib/initializers/TestHelper.ts +++ b/howdju-service-common/lib/initializers/TestHelper.ts @@ -54,6 +54,18 @@ export default class TestHelper { return mediaExcerpt; } + async makeProposition(authToken: AuthToken) { + const createProposition = { + text: "The proposition text", + }; + const { proposition } = + await this.servicesProvider.propositionsService.readOrCreateProposition( + authToken, + createProposition + ); + return proposition; + } + async makeUser() { const now = moment(); const creatorUserId = null; diff --git a/howdju-service-common/lib/initializers/daosInit.ts b/howdju-service-common/lib/initializers/daosInit.ts index 3178e67d..d0a2576d 100644 --- a/howdju-service-common/lib/initializers/daosInit.ts +++ b/howdju-service-common/lib/initializers/daosInit.ts @@ -83,17 +83,26 @@ export function daosInitializer(provider: DatabaseProvider) { sourceExcerptParaphrasesDao ); const statementsDao = new StatementsDao(logger, database, propositionsDao); + const persorgsDao = new PersorgsDao(logger, database); + const sourcesDao = new SourcesDao(database); + const mediaExcerptsDao = new MediaExcerptsDao( + logger, + database, + urlsDao, + sourcesDao, + persorgsDao + ); const justificationsDao = new JustificationsDao( logger, database, statementsDao, propositionCompoundsDao, + mediaExcerptsDao, writQuotesDao, justificationBasisCompoundsDao, writQuoteUrlTargetsDao ); const permissionsDao = new PermissionsDao(logger, database); - const persorgsDao = new PersorgsDao(logger, database); const perspectivesDao = new PerspectivesDao(logger, database); const userExternalIdsDao = new UserExternalIdsDao(database); const userGroupsDao = new UserGroupsDao(database); @@ -104,14 +113,6 @@ export function daosInitializer(provider: DatabaseProvider) { const propositionTagVotesDao = new PropositionTagVotesDao(logger, database); const propositionTagsDao = new PropositionTagsDao(logger, database); const tagsDao = new TagsDao(logger, database); - const sourcesDao = new SourcesDao(database); - const mediaExcerptsDao = new MediaExcerptsDao( - logger, - database, - urlsDao, - sourcesDao, - persorgsDao - ); logger.debug("daosInit complete"); diff --git a/howdju-service-common/lib/initializers/servicesInit.ts b/howdju-service-common/lib/initializers/servicesInit.ts index 57591c28..117ad2ff 100644 --- a/howdju-service-common/lib/initializers/servicesInit.ts +++ b/howdju-service-common/lib/initializers/servicesInit.ts @@ -141,6 +141,17 @@ export function servicesInitializer(provider: AwsProvider) { provider.justificationBasisCompoundsDao ); + const sourcesService = new SourcesService(provider.sourcesDao); + + const mediaExcerptsService = new MediaExcerptsService( + authService, + provider.mediaExcerptsDao, + writQuotesService, + sourcesService, + persorgsService, + urlsService + ); + const justificationsService = new JustificationsService( provider.appConfig, provider.logger, @@ -150,6 +161,7 @@ export function servicesInitializer(provider: AwsProvider) { statementsService, writQuotesService, propositionCompoundsService, + mediaExcerptsService, justificationBasisCompoundsService, provider.justificationsDao, provider.permissionsDao @@ -229,17 +241,6 @@ export function servicesInitializer(provider: AwsProvider) { justificationsService ); - const sourcesService = new SourcesService(provider.sourcesDao); - - const mediaExcerptsService = new MediaExcerptsService( - authService, - provider.mediaExcerptsDao, - writQuotesService, - sourcesService, - persorgsService, - urlsService - ); - provider.logger.debug("servicesInit complete"); return { diff --git a/howdju-service-common/lib/serviceErrors.ts b/howdju-service-common/lib/serviceErrors.ts index e3e075be..600242fc 100644 --- a/howdju-service-common/lib/serviceErrors.ts +++ b/howdju-service-common/lib/serviceErrors.ts @@ -52,10 +52,10 @@ export class InvalidLoginError extends HowdjuApiError {} export class EntityNotFoundError extends HowdjuApiError { entityType: EntityType; - identifier: EntityId | undefined; - constructor(entityType: EntityType, identifier?: EntityId) { + identifier: EntityId | EntityId[] | undefined; + constructor(entityType: EntityType, identifier?: EntityId | EntityId[]) { super( - `(EntityNotFoundError) entityType: ${entityType}; identifier: ${identifier}` + `(EntityNotFoundError) entityType: ${entityType}; identifier(s): ${identifier}` ); this.entityType = entityType; diff --git a/howdju-service-common/lib/services/JustificationsService.test.ts b/howdju-service-common/lib/services/JustificationsService.test.ts index 164a46fe..a59e33eb 100644 --- a/howdju-service-common/lib/services/JustificationsService.test.ts +++ b/howdju-service-common/lib/services/JustificationsService.test.ts @@ -11,7 +11,7 @@ import { negateRootPolarity, SortDescription, } from "howdju-common"; -import { mockLogger } from "howdju-test-common"; +import { mockLogger, expectToBeSameMomentDeep } from "howdju-test-common"; import { endPoolAndDropDb, initDb, makeTestDbConfig } from "@/util/testUtil"; import { @@ -27,6 +27,7 @@ import { WritQuotesService, } from ".."; import { makeTestProvider } from "@/initializers/TestProvider"; +import TestHelper from "@/initializers/TestHelper"; const dbConfig = makeTestDbConfig(); @@ -39,6 +40,7 @@ describe("JustificationsService", () => { let authService: AuthService; let propositionCompoundsService: PropositionCompoundsService; let writQuotesService: WritQuotesService; + let testHelper: TestHelper; beforeEach(async () => { dbName = await initDb(dbConfig); @@ -52,6 +54,7 @@ describe("JustificationsService", () => { authService = provider.authService; propositionCompoundsService = provider.propositionCompoundsService; writQuotesService = provider.writQuotesService; + testHelper = provider.testHelper; }); afterEach(async () => { await endPoolAndDropDb(pool, dbConfig, dbName); @@ -536,6 +539,53 @@ describe("JustificationsService", () => { id: expect.any(String), }); }); + + test("can read a media excerpt based justification for a proposition root target", async () => { + const { user, authToken } = await makeUser(); + + const proposition = await testHelper.makeProposition(authToken); + const mediaExcerpt = await testHelper.makeMediaExcerpt(authToken); + const createJustification: CreateJustification = { + target: { + type: "PROPOSITION", + entity: proposition, + }, + basis: { + type: "MEDIA_EXCERPT", + entity: mediaExcerpt, + }, + polarity: "POSITIVE", + }; + + const { justification } = await service.readOrCreate( + createJustification, + authToken + ); + + // Act + const justifications = await service.readJustificationsForRootTarget( + "PROPOSITION", + proposition.id, + user.id + ); + + // Assert + const expectedJustification = { + ...justification, + rootTarget: { + id: proposition.id, + }, + target: { + type: "PROPOSITION", + entity: { + id: proposition.id, + }, + }, + }; + expect(justifications).toEqual( + expectToBeSameMomentDeep([expectedJustification]) + ); + }); }); describe("deleteJustification", () => { test("can delete justification", async () => { diff --git a/howdju-service-common/lib/services/JustificationsService.ts b/howdju-service-common/lib/services/JustificationsService.ts index ce3e752b..b4dc4a5b 100644 --- a/howdju-service-common/lib/services/JustificationsService.ts +++ b/howdju-service-common/lib/services/JustificationsService.ts @@ -71,6 +71,7 @@ import { AuthService, JustificationBasisCompoundsService, JustificationsDao, + MediaExcerptsService, PermissionsDao, PropositionCompoundsService, PropositionsService, @@ -99,6 +100,7 @@ export class JustificationsService extends EntityService< statementsService: StatementsService; writQuotesService: WritQuotesService; propositionCompoundsService: PropositionCompoundsService; + mediaExcerptsService: MediaExcerptsService; justificationBasisCompoundsService: JustificationBasisCompoundsService; justificationsDao: JustificationsDao; permissionsDao: PermissionsDao; @@ -112,6 +114,7 @@ export class JustificationsService extends EntityService< statementsService: StatementsService, writQuotesService: WritQuotesService, propositionCompoundsService: PropositionCompoundsService, + mediaExcerptsService: MediaExcerptsService, justificationBasisCompoundsService: JustificationBasisCompoundsService, justificationsDao: JustificationsDao, permissionsDao: PermissionsDao @@ -126,6 +129,7 @@ export class JustificationsService extends EntityService< statementsService, writQuotesService, propositionCompoundsService, + mediaExcerptsService, justificationBasisCompoundsService, justificationsDao, permissionsDao, @@ -138,6 +142,7 @@ export class JustificationsService extends EntityService< this.statementsService = statementsService; this.writQuotesService = writQuotesService; this.propositionCompoundsService = propositionCompoundsService; + this.mediaExcerptsService = mediaExcerptsService; this.justificationBasisCompoundsService = justificationBasisCompoundsService; this.justificationsDao = justificationsDao; @@ -507,6 +512,10 @@ export class JustificationsService extends EntityService< return await this.propositionCompoundsService.readPropositionCompoundForId( justificationBasis.entity.id ); + case "MEDIA_EXCERPT": + return await this.mediaExcerptsService.readMediaExcerptForId( + justificationBasis.entity.id + ); case "SOURCE_EXCERPT": // TODO(201): implement throw newUnimplementedError( @@ -682,6 +691,25 @@ export class JustificationsService extends EntityService< }; } + case "MEDIA_EXCERPT": { + const mediaExcerpt = await prefixErrorPath( + this.mediaExcerptsService.readMediaExcerptForId( + justificationBasis.entity.id + ), + "entity" + ); + if (!mediaExcerpt) { + throw new EntityNotFoundError( + "MEDIA_EXCERPT", + justificationBasis.entity.id + ); + } + return { + isExtant: true, + basis: { type, entity: mediaExcerpt }, + }; + } + case "SOURCE_EXCERPT": // TODO(201): implement throw newUnimplementedError( diff --git a/premiser-ui/src/JustificationBasisViewer.tsx b/premiser-ui/src/JustificationBasisViewer.tsx index 9fbfef54..16fa45d4 100644 --- a/premiser-ui/src/JustificationBasisViewer.tsx +++ b/premiser-ui/src/JustificationBasisViewer.tsx @@ -3,22 +3,22 @@ import React from "react"; import { ContextTrailItem, JustificationBasisTypes, - JustificationOut, + JustificationView, newExhaustedEnumError, } from "howdju-common"; import PropositionCompoundViewer from "./PropositionCompoundViewer"; import WritQuoteEntityViewer from "./WritQuoteEntityViewer"; import { ComponentId } from "./types"; -import SourceExcerptEntityViewer from "./SourceExcerptEntityViewer"; import { OnClickWritQuoteUrl } from "./WritQuoteViewer"; import { combineEditorIds } from "./viewModels"; +import MediaExcerptViewer from "./components/mediaExcerpts/MediaExcerptViewer"; import "./JustificationBasisViewer.scss"; interface Props { id: ComponentId; - justification: JustificationOut; + justification: JustificationView; showStatusText: boolean; showUrls: boolean; contextTrailItems?: ContextTrailItem[]; @@ -50,6 +50,8 @@ function makeBasisViewer({ contextTrailItems={contextTrailItems} /> ); + case "MEDIA_EXCERPT": + return ; case JustificationBasisTypes.WRIT_QUOTE: return ( ); - case "SOURCE_EXCERPT": - return ; default: throw newExhaustedEnumError(basis); } diff --git a/premiser-ui/src/JustificationBranch.tsx b/premiser-ui/src/JustificationBranch.tsx index 7e6ddaf0..7f5906dc 100644 --- a/premiser-ui/src/JustificationBranch.tsx +++ b/premiser-ui/src/JustificationBranch.tsx @@ -23,9 +23,8 @@ import { JustificationSearchFilters, newExhaustedEnumError, makeCreateCounterJustificationInput, - JustificationOut, ContextTrailItem, - newUnimplementedError, + JustificationView, } from "howdju-common"; import { isVerified, isDisverified } from "howdju-client-common"; @@ -49,7 +48,7 @@ import { OnClickJustificationWritQuoteUrl } from "./types"; import { logger } from "./logger"; interface OwnProps { - justification: JustificationOut; + justification: JustificationView; doShowControls?: boolean; doShowBasisJustifications: boolean; isCondensed?: boolean; @@ -124,13 +123,6 @@ function JustificationBranch({ const justificationBasis = justification.basis; const basisEditorType = justificationBasis.type; switch (basisEditorType) { - case "PROPOSITION_COMPOUND": - logger.error("Unable to edit proposition compound justification basis"); - break; - case "SOURCE_EXCERPT": - throw newUnimplementedError( - "Cannot edit SourceExcerpt justification basis." - ); case "WRIT_QUOTE": dispatch( editors.beginEdit( @@ -140,6 +132,10 @@ function JustificationBranch({ ) ); break; + case "PROPOSITION_COMPOUND": + case "MEDIA_EXCERPT": + logger.error(`Unable to edit ${basisEditorType} justification basis`); + break; } } @@ -162,10 +158,9 @@ function JustificationBranch({ case "PROPOSITION_COMPOUND": params.propositionCompoundId = justificationBasis.entity.id; break; - case "SOURCE_EXCERPT": - throw newUnimplementedError( - "Cannot filter justification by basis SourceExcerpt." - ); + case "MEDIA_EXCERPT": + params.mediaExcerptId = justificationBasis.entity.id; + break; default: throw newExhaustedEnumError(justificationBasis); } diff --git a/premiser-ui/src/JustificationChatBubble.tsx b/premiser-ui/src/JustificationChatBubble.tsx index 6ea08f7e..22209b90 100644 --- a/premiser-ui/src/JustificationChatBubble.tsx +++ b/premiser-ui/src/JustificationChatBubble.tsx @@ -4,18 +4,19 @@ import { ContextTrailItem, isRootNegative, isRootPositive, - JustificationOut, + JustificationView, } from "howdju-common"; + import JustificationBasisViewer from "./JustificationBasisViewer"; import ChatBubble from "./ChatBubble"; - -import "./JustificationChatBubble.scss"; import { ComponentId, OnClickJustificationWritQuoteUrl } from "./types"; import { OnClickWritQuoteUrl } from "./WritQuoteViewer"; +import "./JustificationChatBubble.scss"; + interface Props extends HTMLAttributes { id?: ComponentId; - justification: JustificationOut; + justification: JustificationView; doShowControls: boolean; showStatusText: boolean; showBasisUrls: boolean; diff --git a/premiser-ui/src/SourceExcerptEntityViewer.js b/premiser-ui/src/SourceExcerptEntityViewer.js index d556ef82..418e51ed 100644 --- a/premiser-ui/src/SourceExcerptEntityViewer.js +++ b/premiser-ui/src/SourceExcerptEntityViewer.js @@ -6,6 +6,7 @@ import { sourceExcerptDescription, sourceExcerptIconName } from "./viewModels"; import EntityViewer from "./EntityViewer"; import SourceExcerptViewer from "./SourceExcerptViewer"; +/** @deprecated TODO(38) remove in favor of MediaExcerpts */ export default class SourceExcerptEntityViewer extends Component { render() { const { diff --git a/premiser-ui/src/__snapshots__/ListEntitiesWidget.test.tsx.snap b/premiser-ui/src/__snapshots__/ListEntitiesWidget.test.tsx.snap index 79f9c6b5..22a455b6 100644 --- a/premiser-ui/src/__snapshots__/ListEntitiesWidget.test.tsx.snap +++ b/premiser-ui/src/__snapshots__/ListEntitiesWidget.test.tsx.snap @@ -26,7 +26,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 0 @@ -111,7 +111,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 1 @@ -176,7 +176,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 2 @@ -241,7 +241,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 3 @@ -306,7 +306,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 4 @@ -371,7 +371,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 5 @@ -436,7 +436,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows initial recent propos class="entity-viewer--header" > The proposition text 6 @@ -527,7 +527,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 0 @@ -592,7 +592,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 1 @@ -657,7 +657,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 2 @@ -722,7 +722,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 3 @@ -807,7 +807,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 4 @@ -872,7 +872,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 5 @@ -937,7 +937,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 6 @@ -1002,7 +1002,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 7 @@ -1067,7 +1067,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 8 @@ -1132,7 +1132,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 9 @@ -1197,7 +1197,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 10 @@ -1262,7 +1262,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 11 @@ -1327,7 +1327,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 12 @@ -1392,7 +1392,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 13 @@ -1457,7 +1457,7 @@ exports[`ListEntitiesWidget RecentPropositionsWidget shows more propositions whe class="entity-viewer--header" > The proposition text 14 diff --git a/premiser-ui/src/components/mediaExcerpts/MediaExcerptEntityViewer.tsx b/premiser-ui/src/components/mediaExcerpts/MediaExcerptEntityViewer.tsx index ec1448be..c8b8dc6a 100644 --- a/premiser-ui/src/components/mediaExcerpts/MediaExcerptEntityViewer.tsx +++ b/premiser-ui/src/components/mediaExcerpts/MediaExcerptEntityViewer.tsx @@ -1,10 +1,11 @@ import React from "react"; import { MaterialSymbol } from "react-material-symbols"; +import { MediaExcerptView } from "howdju-common"; + import withEntityViewer from "@/withEntityViewer"; import paths from "@/paths"; import MediaExcerptViewer from "./MediaExcerptViewer"; -import { MediaExcerptView } from "@/viewModels"; export default withEntityViewer<"mediaExcerpt", MediaExcerptView>( MediaExcerptViewer, diff --git a/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx b/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx index 3f38fdf1..05e37c37 100644 --- a/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx +++ b/premiser-ui/src/components/mediaExcerpts/MediaExcerptViewer.tsx @@ -1,16 +1,22 @@ import React from "react"; +import { MediaExcerptView } from "howdju-common"; + import CollapsibleTextViewer from "@/components/collapsableText/CollapsableTextViewer"; import MediaExcerptCitationViewer from "./MediaExcerptCitationViewer"; -import { MediaExcerptView } from "@/viewModels"; import "./MediaExcerptViewer.scss"; +import { MaterialSymbol } from "react-material-symbols"; interface Props { mediaExcerpt: MediaExcerptView; } -// TODO(20) click URL: if extension installed, navigate to URL and highlight anchor. +// TODO(38) group the urlLocators by URL and show a count of the number next to the anchor icon. +// If the user has the extension installed, then clicking on the anchor icon should open the +// extension and show the user the anchors for that URL. The user should be able to iterate through +// each distinct UrlLocator. +// Otherwise construct a text anchor URL and open it in a new tab. export default function MediaExcerptViewer({ mediaExcerpt }: Props) { return ( @@ -23,7 +29,12 @@ export default function MediaExcerptViewer({ mediaExcerpt }: Props) { {mediaExcerpt.locators.urlLocators.map( (urlLocator: MediaExcerptView["locators"]["urlLocators"][number]) => (
  • - {urlLocator.url.url} + + {urlLocator.url.url}{" "} + {urlLocator.anchors?.length && ( + + )} +
  • ) )} diff --git a/premiser-ui/src/editors/JustificationEditorFields.tsx b/premiser-ui/src/editors/JustificationEditorFields.tsx index b072d599..f2eb4b38 100644 --- a/premiser-ui/src/editors/JustificationEditorFields.tsx +++ b/premiser-ui/src/editors/JustificationEditorFields.tsx @@ -8,6 +8,7 @@ import { JustificationBasisTypes, CreateJustificationInput, isRef, + isMediaExcerptBased, } from "howdju-common"; import t, { @@ -31,6 +32,7 @@ import { makeErrorPropCreator } from "@/modelErrorMessages"; import { logger } from "@/logger"; import "./JustificationEditorFields.scss"; +import MediaExcerptViewer from "@/components/mediaExcerpts/MediaExcerptViewer"; const polarityName = "polarity"; const propositionCompoundName = "basis.propositionCompound"; @@ -103,9 +105,12 @@ export default function JustificationEditorFields(props: Props) { const onChange = toOnChangeCallback(onPropertyChange); const basisPropositionCompound = justification?.basis.propositionCompound; + const mediaExcerpt = justification?.basis.mediaExcerpt; const basisWritQuote = justification?.basis.writQuote; const _isPropositionCompoundBased = justification && isPropositionCompoundBased(justification); + const _isMediaExcerptBased = + justification && isMediaExcerptBased(justification); const _isWritQuoteBased = justification && isWritQuoteBased(justification); const commonFieldsProps = { onBlur, @@ -145,6 +150,9 @@ export default function JustificationEditorFields(props: Props) { editorDispatch={editorDispatch} /> ); + const mediaExcerptViewer = mediaExcerpt && !isRef(mediaExcerpt) && ( + + ); const writQuoteEditorFields = basisWritQuote && !isRef(basisWritQuote) && ( ) => { const searchText = this.mainSearchText(location); + if (!searchText) { + return false; + } const searchPath = paths.mainSearch(searchText); const actualPath = createPath(location); return searchText && searchPath === actualPath; diff --git a/premiser-ui/src/pages/MediaExcerptPage.tsx b/premiser-ui/src/pages/MediaExcerptPage.tsx index 9c36cb14..238ad6c7 100644 --- a/premiser-ui/src/pages/MediaExcerptPage.tsx +++ b/premiser-ui/src/pages/MediaExcerptPage.tsx @@ -10,14 +10,16 @@ import { } from "react-md"; import { MaterialSymbol } from "react-material-symbols"; -import { EntityId } from "howdju-common"; +import { EntityId, MediaExcerptView } from "howdju-common"; import { useAppDispatch, useAppSelector } from "@/hooks"; import { api } from "@/apiActions"; import { mediaExcerptSchema } from "@/normalizationSchemas"; -import { combineIds, MediaExcerptView } from "@/viewModels"; +import { combineIds } from "@/viewModels"; import MediaExcerptCard from "@/components/mediaExcerpts/MediaExcerptCard"; import HowdjuHelmet from "@/Helmet"; +import paths from "@/paths"; +import { Link } from "react-router-dom"; interface MatchParams { mediaExcerptId: EntityId; @@ -33,10 +35,10 @@ export default function MediaExcerptPage(props: Props) { dispatch(api.fetchMediaExcerpt(mediaExcerptId)); }, [dispatch, mediaExcerptId]); - // eslint-disable-next-line @typescript-eslint/no-empty-function -- TODO(38) remove this. - function useInJustification() {} // eslint-disable-next-line @typescript-eslint/no-empty-function -- TODO(20) remove this. function useInAppearance() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function -- TODO(38) remove this. + function deleteMediaExcerpt() {} // TODO(17): pass props directly after upgrading react-md to a version with correct types const menuClassNameProps = { menuClassName: "context-menu" } as any; @@ -51,8 +53,10 @@ export default function MediaExcerptPage(props: Props) { } - onClick={useInJustification} + component={Link} + to={paths.createJustification("MEDIA_EXCERPT", mediaExcerptId)} />, } - onClick={useInJustification} + onClick={deleteMediaExcerpt} />, ]} /> diff --git a/premiser-ui/src/pages/justifications/__snapshots__/JustificationsPage.test.tsx.snap b/premiser-ui/src/pages/justifications/__snapshots__/JustificationsPage.test.tsx.snap index 7c56dc1c..a0fbb9ea 100644 --- a/premiser-ui/src/pages/justifications/__snapshots__/JustificationsPage.test.tsx.snap +++ b/premiser-ui/src/pages/justifications/__snapshots__/JustificationsPage.test.tsx.snap @@ -31,7 +31,7 @@ exports[`JustificationsPage Can add a compound-based justification 1`] = ` class="entity-viewer--header" > the-proposition-text @@ -439,7 +439,7 @@ exports[`JustificationsPage Shows a justified proposition 1`] = ` class="entity-viewer--header" > the-proposition-text diff --git a/premiser-ui/src/paths.js b/premiser-ui/src/paths.ts similarity index 61% rename from premiser-ui/src/paths.js rename to premiser-ui/src/paths.ts index dfe401d5..80ee5c78 100644 --- a/premiser-ui/src/paths.js +++ b/premiser-ui/src/paths.ts @@ -1,15 +1,30 @@ -import { createPath } from "history"; +import { createPath, LocationDescriptorObject } from "history"; import isEmpty from "lodash/isEmpty"; import queryString from "query-string"; import { + ContextTrailItem, + CreatePersorgInput, + EntityId, + JustificationBasisSourceType, + JustificationOut, JustificationRootTargetTypes, + JustificationSearchFilters, + MediaExcerptRef, newExhaustedEnumError, + PersorgOut, serializeContextTrail, + SourceOut, + StatementRef, + TagOut, toSlug, + UpdatePersorgInput, + WritQuoteOut, + WritRef, } from "howdju-common"; import { logger } from "./logger"; +import { PropositionRefView } from "./viewModels"; export const mainSearchPathName = "/"; @@ -29,56 +44,65 @@ class Paths { requestPasswordReset = () => "/request-password-reset"; proposition = ( - proposition, - contextTrailItems, + proposition: PropositionRefView, + contextTrailItems?: ContextTrailItem[], noSlug = false, - focusJustificationId = null + focusJustificationId: EntityId | null = null ) => { const { id, slug } = proposition; if (!id) { return "#"; } const slugPath = !noSlug && slug ? "/" + slug : ""; - const query = !isEmpty(contextTrailItems) - ? "?context-trail=" + serializeContextTrail(contextTrailItems) - : ""; + const query = + contextTrailItems && !isEmpty(contextTrailItems) + ? "?context-trail=" + serializeContextTrail(contextTrailItems) + : ""; const anchor = focusJustificationId ? `#justification-${focusJustificationId}` : ""; return `/p/${id}${slugPath}${anchor}${query}`; }; - statement = (statement, focusJustificationId = null) => { + statement = ( + statement: StatementRef, + focusJustificationId: EntityId | null = null + ) => { const anchor = focusJustificationId ? `#justification-${focusJustificationId}` : ""; return `/s/${statement.id}${anchor}`; }; - justification = (j) => { + justification = (j: JustificationOut) => { switch (j.rootTargetType) { case JustificationRootTargetTypes.PROPOSITION: - return this.proposition(j.rootTarget, null, false, j.id); + return this.proposition(j.rootTarget, [], false, j.id); case JustificationRootTargetTypes.STATEMENT: return this.statement(j.rootTarget, j.id); default: - throw newExhaustedEnumError(j.rootTargetType); + throw newExhaustedEnumError(j); } }; - persorg = (persorg) => `/persorgs/${persorg.id}/${toSlug(persorg.name)}`; + persorg = (persorg: CreatePersorgInput | UpdatePersorgInput | PersorgOut) => + `/persorgs/${persorg.id}/${toSlug(persorg.name)}`; - writQuote = (writQuote) => + writQuote = (writQuote: WritQuoteOut) => `/writ-quotes/${writQuote.id}/${toSlug(writQuote.writ.title)}`; - writUsages = (writ) => this.searchJustifications({ writId: writ.id }); - writQuoteUsages = (writQuote) => { + writUsages = (writ: WritRef) => + this.searchJustifications({ writId: writ.id }); + writQuoteUsages = (writQuote: WritQuoteOut) => { if (!writQuote.id) { return "#"; } return this.searchJustifications({ writQuoteId: writQuote.id }); }; - createJustification = (basisSourceType, basisSourceId) => { - const location = { + createJustification = ( + basisSourceType: JustificationBasisSourceType, + basisSourceId: EntityId + ) => { + const location: LocationDescriptorObject = { pathname: createJustificationPath, }; if (basisSourceType || basisSourceId) { @@ -92,23 +116,25 @@ class Paths { } return createPath(location); }; - searchJustifications = (params) => + searchJustifications = (params: JustificationSearchFilters) => createPath({ pathname: "/search-justifications", search: "?" + queryString.stringify(params), }); - mainSearch = (mainSearchText) => + mainSearch = (mainSearchText: string) => createPath({ pathname: mainSearchPathName, search: "?" + window.encodeURIComponent(mainSearchText), }); - propositionUsages = (propositionId) => + propositionUsages = (propositionId: EntityId) => `/proposition-usages?propositionId=${propositionId}`; - statementUsages = (statementId) => + statementUsages = (statementId: EntityId) => `/statement-usages?propositionId=${statementId}`; - mediaExcerpt = (mediaExcerpt) => `/media-excerpts/${mediaExcerpt.id}`; - source = (source) => `/sources/${source.id}/${toSlug(source.descriptionApa)}`; + mediaExcerpt = (mediaExcerpt: MediaExcerptRef) => + `/media-excerpts/${mediaExcerpt.id}`; + source = (source: SourceOut) => + `/sources/${source.id}/${toSlug(source.descriptionApa)}`; submitMediaExcerpt = () => "/media-excerpts/new"; tools = () => "/tools"; @@ -124,7 +150,7 @@ class Paths { cookieNotice = () => "/policies/cookie-notice"; faq = () => "/faq"; - tag = (tag) => `/tags/${tag.id}/${toSlug(tag.name)}`; + tag = (tag: TagOut) => `/tags/${tag.id}/${toSlug(tag.name)}`; } export default new Paths(); diff --git a/premiser-ui/src/reducers/entities.js b/premiser-ui/src/reducers/entities.js index c319bcc4..8007e767 100644 --- a/premiser-ui/src/reducers/entities.js +++ b/premiser-ui/src/reducers/entities.js @@ -25,10 +25,21 @@ import { isTruthy, JustificationTargetTypes, newExhaustedEnumError, + toSlug, } from "howdju-common"; import { api } from "../actions"; +function composeCustomizers(...customizers) { + return (oldEntity, newEntity, key, object, source) => { + let result = oldEntity; + forEach(customizers, (customizer) => { + result = customizer(result, newEntity, key, object, source); + }); + return result; + }; +} + const defaultState = { contextTrailItems: {}, justifications: {}, @@ -96,7 +107,13 @@ export default handleActions( ["mediaExcerpts", mediaExcerptCustomizer], ["persorgs", persorgCustomizer], ["propositionCompounds"], - ["propositions", entityAssignWithCustomizer], + [ + "propositions", + composeCustomizers( + entityAssignWithCustomizer, + propositionCustomizer + ), + ], ["propositionTagVotes"], ["sources"], ["sourceExcerptParaphrases"], @@ -530,6 +547,19 @@ function mediaExcerptCustomizer(oldExcerpt, newExcerpt, key, object, source) { }); } +function propositionCustomizer( + oldProposition, + newProposition, + key, + object, + source +) { + return merge({}, oldProposition, newProposition, { + key: newProposition.id, + slug: toSlug(newProposition.text), + }); +} + function createEntityUpdate(state, payloadEntities, key, customizer) { if (has(payloadEntities, key)) { return { diff --git a/premiser-ui/src/sagas/editors/fetchAndBeginEditOfNewJustificationFromBasisSourceSaga.ts b/premiser-ui/src/sagas/editors/fetchAndBeginEditOfNewJustificationFromBasisSourceSaga.ts index 35551935..9afee193 100644 --- a/premiser-ui/src/sagas/editors/fetchAndBeginEditOfNewJustificationFromBasisSourceSaga.ts +++ b/premiser-ui/src/sagas/editors/fetchAndBeginEditOfNewJustificationFromBasisSourceSaga.ts @@ -20,6 +20,7 @@ import { PropositionOut, WritQuoteOut, PropositionCompoundOut, + MediaExcerptOut, } from "howdju-common"; import { api, editors, flows, str } from "@/actions"; @@ -59,12 +60,17 @@ export function* fetchAndBeginEditOfNewJustificationFromBasisSource() { let propositionCompound: CreatePropositionCompoundInput | undefined; let writQuote: CreateWritQuoteInput | undefined; let sourceExcerpt: CreateSourceExcerptInput | undefined; + let mediaExcerpt: MediaExcerptOut | undefined; switch (alternatives.basisType) { case JustificationBasisSourceTypes.PROPOSITION_COMPOUND: type = "PROPOSITION_COMPOUND"; propositionCompound = alternatives.propositionCompound; break; + case "MEDIA_EXCERPT": + type = "MEDIA_EXCERPT"; + mediaExcerpt = alternatives.mediaExcerpt; + break; case JustificationBasisSourceTypes.PROPOSITION: { type = "PROPOSITION_COMPOUND"; const proposition = alternatives.proposition; @@ -87,6 +93,7 @@ export function* fetchAndBeginEditOfNewJustificationFromBasisSource() { propositionCompound || makeCreatePropositionCompoundInput(), writQuote: writQuote || makeCreateWritQuoteInput(), sourceExcerpt: sourceExcerpt || makeCreateSourceExcerptInput(), + mediaExcerpt: mediaExcerpt || undefined, }; const model = makeCreateJustifiedSentenceInput({}, { basis }); @@ -106,6 +113,8 @@ function fetchActionCreatorForBasisSourceType( case JustificationBasisSourceTypes.PROPOSITION: return api.fetchProposition; case JustificationBasisSourceTypes.JUSTIFICATION_BASIS_COMPOUND: + case "MEDIA_EXCERPT": + return api.fetchMediaExcerpt; case JustificationBasisSourceTypes.SOURCE_EXCERPT_PARAPHRASE: throw newUnimplementedError(`Unsupported basis type: ${basisType}`); default: @@ -113,24 +122,34 @@ function fetchActionCreatorForBasisSourceType( } } -type PropositionSourceAlternatives = +type JustificationBasisAlternatives = | { basisType: "PROPOSITION_COMPOUND"; propositionCompound: PropositionCompoundOut; writQuote: undefined; proposition: undefined; + mediaExcerpt: undefined; } | { basisType: "WRIT_QUOTE"; propositionCompound: undefined; writQuote: WritQuoteOut; proposition: undefined; + mediaExcerpt: undefined; } | { basisType: "PROPOSITION"; propositionCompound: undefined; writQuote: undefined; proposition: PropositionOut; + mediaExcerpt: undefined; + } + | { + basisType: "MEDIA_EXCERPT"; + propositionCompound: undefined; + writQuote: undefined; + proposition: undefined; + mediaExcerpt: MediaExcerptOut; }; function extractBasisSourceFromFetchResponseAction( @@ -139,14 +158,16 @@ function extractBasisSourceFromFetchResponseAction( propositionCompound: PropositionCompoundOut; writQuote: WritQuoteOut; proposition: PropositionOut; + mediaExcerpt: MediaExcerptOut; }> -): PropositionSourceAlternatives { - const { propositionCompound, writQuote, proposition } = +): JustificationBasisAlternatives { + const { propositionCompound, writQuote, proposition, mediaExcerpt } = fetchResponseAction.payload; const alternatives = { propositionCompound: undefined, writQuote: undefined, proposition: undefined, + mediaExcerpt: undefined, }; switch (basisType) { case "PROPOSITION_COMPOUND": @@ -163,6 +184,8 @@ function extractBasisSourceFromFetchResponseAction( }; case "PROPOSITION": return { ...alternatives, basisType, proposition }; + case "MEDIA_EXCERPT": + return { ...alternatives, basisType, mediaExcerpt }; case "JUSTIFICATION_BASIS_COMPOUND": case "SOURCE_EXCERPT_PARAPHRASE": throw newUnimplementedError(`Unsupported basis type: ${basisType}`); diff --git a/premiser-ui/src/viewModels.ts b/premiser-ui/src/viewModels.ts index a7a3654f..f74edae6 100644 --- a/premiser-ui/src/viewModels.ts +++ b/premiser-ui/src/viewModels.ts @@ -30,7 +30,7 @@ import { ConnectingEntityType, ContextTrailItem, nextContextTrailItem, - MediaExcerptOut, + PropositionRef, } from "howdju-common"; import * as characters from "./characters"; @@ -239,19 +239,8 @@ export function extendContextTrailItems( return concat(contextTrailItems, [trailItem]); } -export interface MediaExcerptView extends MediaExcerptOut { - citations: (MediaExcerptOut["citations"][number] & { - /** A key uniquely identifying a citation relative to others. */ - key: string; - })[]; - locators: MediaExcerptOut["locators"] & { - urlLocators: (MediaExcerptOut["locators"]["urlLocators"][number] & { - /** A key uniquely identifying a url locator relative to others. */ - key: string; - })[]; - }; - speakers: (MediaExcerptOut["speakers"][number] & { - /** A key uniquely identifying a persorg relative to others. */ - key: string; - })[]; +/** Information sufficient to reference a proposition in the UI. */ +export interface PropositionRefView extends PropositionRef { + /** A slugified version of the proposition text. */ + slug?: string; } diff --git a/premiser-ui/src/withEntityViewer.tsx b/premiser-ui/src/withEntityViewer.tsx index f929bc20..768c368a 100644 --- a/premiser-ui/src/withEntityViewer.tsx +++ b/premiser-ui/src/withEntityViewer.tsx @@ -29,7 +29,7 @@ export default function withEntityViewer< entityPropName: EntityPropName, iconName: string | ReactNode, iconTitle: string, - entityLinkFn: (entity: object) => string + entityLinkFn: (entity: Entity) => string ) { type EntityViewerWrapperProps = { component?: ComponentType; @@ -62,7 +62,7 @@ export default function withEntityViewer< }