diff --git a/build/ci/integration-values.yaml b/build/ci/integration-values.yaml index 007a28c360..1127d6e5cc 100644 --- a/build/ci/integration-values.yaml +++ b/build/ci/integration-values.yaml @@ -57,7 +57,6 @@ localSettings: DEBUG: true DOMAIN_LANGUAGE: {} APSCHEDULER_NAME: "apscheduler-{{ .Values.deployEnv }}" - TURN_SERVER: '' USE_CLOUDFLARE: false FRONT_END_URL: "http://${NAME}.integration.sefaria.org" OFFLINE: false diff --git a/build/ci/production-values.yaml b/build/ci/production-values.yaml index 138910a0f7..aaf6ed61c7 100644 --- a/build/ci/production-values.yaml +++ b/build/ci/production-values.yaml @@ -235,7 +235,6 @@ localSettings: } MONGO_HOST: "mongo" APSCHEDULER_NAME: "apscheduler-{{ .Values.deployEnv }}" - TURN_SERVER: '' USE_CLOUDFLARE: false FRONT_END_URL: "http://www.sefaria.org" OFFLINE: false diff --git a/build/ci/sandbox-values.yaml b/build/ci/sandbox-values.yaml index f6984b77f0..21dc2b826b 100644 --- a/build/ci/sandbox-values.yaml +++ b/build/ci/sandbox-values.yaml @@ -53,7 +53,6 @@ localSettings: DEBUG: false DOMAIN_LANGUAGE: {} APSCHEDULER_NAME: "apscheduler-{{ .Values.deployEnv }}" - TURN_SERVER: '' USE_CLOUDFLARE: false FRONT_END_URL: "http://${NAME}.cauldron.sefaria.org" OFFLINE: false diff --git a/build/notify/notifyEnd.js b/build/notify/notifyEnd.js index 88f4d8d6f3..1edc5d3a29 100644 --- a/build/notify/notifyEnd.js +++ b/build/notify/notifyEnd.js @@ -21,7 +21,7 @@ console.log(` const jobKeys = [ "Jest", - "PyTest", + "Continuous Testing: PyTest", "Playwright", ]; diff --git a/docs/openAPI.json b/docs/openAPI.json index fe97c22686..62c5aa4d1e 100644 --- a/docs/openAPI.json +++ b/docs/openAPI.json @@ -4187,7 +4187,6 @@ }, "categoryDescription": {}, "numSources": 1767, - "good_to_promote": true, "description_published": true, "data_source": "sefaria", "primaryTitle": { @@ -4591,7 +4590,6 @@ "isTopLevelDisplay": true, "displayOrder": 30, "numSources": 2937, - "good_to_promote": true, "primaryTitle": { "en": "Prayer", "he": "תפילה" @@ -4623,7 +4621,6 @@ "_temp_id": "תורה" }, "numSources": 2333, - "good_to_promote": true, "primaryTitle": { "en": "Torah", "he": "תורה" @@ -4766,7 +4763,6 @@ }, "categoryDescription": {}, "numSources": 1967, - "good_to_promote": true, "description_published": true, "data_source": "sefaria", "primaryTitle": { @@ -4869,7 +4865,6 @@ "categoryDescription": {}, "displayOrder": 0, "numSources": 1662, - "good_to_promote": true, "description_published": true, "data_source": "sefaria", "image": { @@ -5162,7 +5157,6 @@ } }, "numSources": 7, - "good_to_promote": true, "primaryTitle": { "en": "Metushelach", "he": "מתושלח" @@ -9761,7 +9755,6 @@ "categoryDescription": {}, "displayOrder": 0, "numSources": 1662, - "good_to_promote": true, "description_published": true, "data_source": "sefaria", "image": { @@ -10071,9 +10064,6 @@ "format": "int32", "type": "integer" }, - "good_to_promote": { - "type": "boolean" - }, "description_published": { "type": "boolean" }, @@ -10089,7 +10079,7 @@ } } }, - "example": "{\n\"slug\": \"metushelach\",\n\"titles\": [\n{\n\"text\": \"Metushelach\",\n\"lang\": \"en\",\n\"primary\": true,\n\"transliteration\": true\n},\n{\n\"text\": \"מתושלח\",\n\"lang\": \"he\",\n\"primary\": true\n},\n{\n\"text\": \"Methuselah\",\n\"lang\": \"en\"\n},\n{\n\"text\": \"Methushelach\",\n\"lang\": \"en\"\n}\n],\n\"subclass\": \"person\",\n\"alt_ids\": {\n\"_temp_id\": \"מתושלח\",\n\"wikidata\": \"Q156290\"\n},\n\"properties\": {\n\"enWikiLink\": {\n\"value\": \"https://en.wikipedia.org/wiki/Methuselah\",\n\"dataSource\": \"wikidata\"\n},\n\"heWikiLink\": {\n\"value\": \"https://he.wikipedia.org/wiki/מתושלח\",\n\"dataSource\": \"wikidata\"\n},\n\"deWikiLink\": {\n\"value\": \"https://de.wikipedia.org/wiki/Methusalem\",\n\"dataSource\": \"wikidata\"\n},\n\"esWikiLink\": {\n\"value\": \"https://es.wikipedia.org/wiki/Matusalén\",\n\"dataSource\": \"wikidata\"\n},\n\"frWikiLink\": {\n\"value\": \"https://fr.wikipedia.org/wiki/Mathusalem\",\n\"dataSource\": \"wikidata\"\n},\n\"ruWikiLink\": {\n\"value\": \"https://ru.wikipedia.org/wiki/Мафусал_(потомок_Сифа)\",\n\"dataSource\": \"wikidata\"\n}\n},\n\"numSources\": 7,\n\"good_to_promote\": true,\n\"primaryTitle\": {\n\"en\": \"Metushelach\",\n\"he\": \"מתושלח\"\n}\n}" + "example": "{\n\"slug\": \"metushelach\",\n\"titles\": [\n{\n\"text\": \"Metushelach\",\n\"lang\": \"en\",\n\"primary\": true,\n\"transliteration\": true\n},\n{\n\"text\": \"מתושלח\",\n\"lang\": \"he\",\n\"primary\": true\n},\n{\n\"text\": \"Methuselah\",\n\"lang\": \"en\"\n},\n{\n\"text\": \"Methushelach\",\n\"lang\": \"en\"\n}\n],\n\"subclass\": \"person\",\n\"alt_ids\": {\n\"_temp_id\": \"מתושלח\",\n\"wikidata\": \"Q156290\"\n},\n\"properties\": {\n\"enWikiLink\": {\n\"value\": \"https://en.wikipedia.org/wiki/Methuselah\",\n\"dataSource\": \"wikidata\"\n},\n\"heWikiLink\": {\n\"value\": \"https://he.wikipedia.org/wiki/מתושלח\",\n\"dataSource\": \"wikidata\"\n},\n\"deWikiLink\": {\n\"value\": \"https://de.wikipedia.org/wiki/Methusalem\",\n\"dataSource\": \"wikidata\"\n},\n\"esWikiLink\": {\n\"value\": \"https://es.wikipedia.org/wiki/Matusalén\",\n\"dataSource\": \"wikidata\"\n},\n\"frWikiLink\": {\n\"value\": \"https://fr.wikipedia.org/wiki/Mathusalem\",\n\"dataSource\": \"wikidata\"\n},\n\"ruWikiLink\": {\n\"value\": \"https://ru.wikipedia.org/wiki/Мафусал_(потомок_Сифа)\",\n\"dataSource\": \"wikidata\"\n}\n},\n\"numSources\": 7,\n\"primaryTitle\": {\n\"en\": \"Metushelach\",\n\"he\": \"מתושלח\"\n}\n}" }, "url": { "description": "The `Ref` in a format appropriate for a URL, with spaces replaced with `.` etc. ", @@ -10135,7 +10125,6 @@ }, "categoryDescription": {}, "numSources": 217, - "good_to_promote": true, "description_published": true, "primaryTitle": { "en": "Hillel", @@ -10170,10 +10159,6 @@ "description": "A description of the category of this topic", "type": "string" }, - "good_to_promote": { - "description": "A topic which will be included in our results from the `random-by-topic` endpoint. ", - "type": "boolean" - }, "numSources": { "description": "The number of text sources associated with a topic. ", "type": "integer", @@ -10222,7 +10207,6 @@ }, "categoryDescription": {}, "numSources": 120, - "good_to_promote": true, "description_published": true, "data_source": "sefaria", "primaryTitle": { diff --git a/helm-chart/sefaria-project/templates/configmap/local-settings-file.yaml b/helm-chart/sefaria-project/templates/configmap/local-settings-file.yaml index d59daf235f..ee9d51ff01 100644 --- a/helm-chart/sefaria-project/templates/configmap/local-settings-file.yaml +++ b/helm-chart/sefaria-project/templates/configmap/local-settings-file.yaml @@ -144,10 +144,6 @@ data: SEARCH_INDEX_NAME_TEXT = 'text' # name of the ElasticSearch index to use SEARCH_INDEX_NAME_SHEET = 'sheet' - TURN_SERVER = os.getenv("TURN_SERVER") #coturn.cauldron.sefaria.org - TURN_SECRET= os.getenv("TURN_SECRET") - TURN_USER = os.getenv("TURN_USER") - USE_NODE = True NODE_HOST = "http://{}:3000".format(nodejsHost) NODE_TIMEOUT = 5 diff --git a/helm-chart/sefaria-project/templates/configmap/local-settings.yaml b/helm-chart/sefaria-project/templates/configmap/local-settings.yaml index efab2f1294..7c3cf997b0 100644 --- a/helm-chart/sefaria-project/templates/configmap/local-settings.yaml +++ b/helm-chart/sefaria-project/templates/configmap/local-settings.yaml @@ -9,7 +9,6 @@ data: DEBUG: "{{ .Values.localSettings.DEBUG }}" DOMAIN_LANGUAGE: {{ .Values.localSettings.DOMAIN_LANGUAGE | toJson | quote }} APSCHEDULER_NAME: {{ tpl .Values.localSettings.APSCHEDULER_NAME . | quote }} - TURN_SERVER: {{ .Values.localSettings.TURN_SERVER | quote }} USE_CLOUDFLARE: "{{ .Values.localSettings.USE_CLOUDFLARE }}" FRONT_END_URL: {{ .Values.localSettings.FRONT_END_URL | quote }} OFFLINE: "{{ .Values.localSettings.OFFLINE }}" diff --git a/helm-chart/sefaria-project/values.yaml b/helm-chart/sefaria-project/values.yaml index 11fd51195f..040e37a7ee 100644 --- a/helm-chart/sefaria-project/values.yaml +++ b/helm-chart/sefaria-project/values.yaml @@ -357,8 +357,6 @@ secrets: # SEFARIA_DB_USER: # SEFARIA_DB_PASSWORD: # SEARCH_URL - # TURN_SECRET: - # TURN_USER: # SEFARIA_BOT_API_KEY: # CLOUDFLARE_ZONE: # CLOUDFLARE_EMAIL: @@ -451,7 +449,6 @@ localSettings: # https://www.sefaria.org: english # https://www.sefaria.org.il: hebrew APSCHEDULER_NAME: "apscheduler-{{ .Values.deployEnv }}" - TURN_SERVER: '' USE_CLOUDFLARE: false FRONT_END_URL: "http://www.sefaria.org" # Use "http://${ENV_NAME}.cauldron.sefaria.org" in cauldrons OFFLINE: "False" diff --git a/reader/views.py b/reader/views.py index 8c843c7955..5c4fd204d9 100644 --- a/reader/views.py +++ b/reader/views.py @@ -4238,7 +4238,7 @@ def random_by_topic_api(request): Returns Texts API data for a random text taken from popular topic tags """ cb = request.GET.get("callback", None) - random_topic = get_random_topic(good_to_promote=True) + random_topic = get_random_topic('torahtab') if random_topic is None: return random_by_topic_api(request) random_source = get_random_topic_source(random_topic) diff --git a/sefaria/helper/category.py b/sefaria/helper/category.py index fe993fb15b..68e9abfd14 100644 --- a/sefaria/helper/category.py +++ b/sefaria/helper/category.py @@ -186,14 +186,15 @@ def update_order_of_category_children(cat, uid, subcategoriesAndBooks): results = [] for subcategoryOrBook in subcategoriesAndBooks: order += 5 - try: - obj = library.get_index(subcategoryOrBook).contents(raw=True) - obj['order'] = [order] - result = tracker.update(uid, Index, obj) - except BookNameError as e: - obj = Category().load({"path": cat_path+[subcategoryOrBook]}).contents() - obj['order'] = order - result = tracker.update(uid, Category, obj) + book = Index().load({'title': subcategoryOrBook, 'categories': cat_path}) + if book: + book = book.contents(raw=True) + book['order'] = [order] + result = tracker.update(uid, Index, book) + else: + cat = Category().load({"path": cat_path+[subcategoryOrBook]}).contents() + cat['order'] = order + result = tracker.update(uid, Category, cat) results.append(result.contents()) return results diff --git a/sefaria/helper/topic.py b/sefaria/helper/topic.py index d6f8aa92bc..8203a06b31 100644 --- a/sefaria/helper/topic.py +++ b/sefaria/helper/topic.py @@ -280,8 +280,12 @@ def curated_primacy(order_dict, lang): return (bord.get('numDatasource', 0) * bord.get('tfidf', 0)) - (aord.get('numDatasource', 0) * aord.get('tfidf', 0)) -def get_random_topic(good_to_promote=True) -> Optional[Topic]: - query = {"good_to_promote": True} if good_to_promote else {} +def get_random_topic(pool=None) -> Optional[Topic]: + """ + :param pool: name of the pool from which to select the topic. If `None`, all topics are considered. + :return: Returns a random topic from the database. If you provide `pool`, then the selection is limited to topics in that pool. + """ + query = {"pools": pool} if pool else {} random_topic_dict = list(db.topics.aggregate([ {"$match": query}, {"$sample": {"size": 1}} @@ -960,33 +964,18 @@ def calculate_popular_writings_for_authors(top_n, min_pr): "order": {"custom_order": rd['pagesheetrank']} }).save() - def recalculate_secondary_topic_data(): - # run before everything else because this creates new links - calculate_popular_writings_for_authors(100, 300) + sheet_source_links = RefTopicLinkSet({'pools': 'textual'}) + sheet_topic_links = RefTopicLinkSet({'pools': 'sheets'}) + sheet_related_links = IntraTopicLinkSet() - sheet_source_links, sheet_related_links, sheet_topic_links = generate_all_topic_links_from_sheets() related_links = update_intra_topic_link_orders(sheet_related_links) - all_ref_links = update_ref_topic_link_orders(sheet_source_links, sheet_topic_links) - - # now that we've gathered all the new links, delete old ones and insert new ones - RefTopicLinkSet({"generatedBy": TopicLinkHelper.generated_by_sheets}).delete() - RefTopicLinkSet({"is_sheet": True}).delete() - IntraTopicLinkSet({"generatedBy": TopicLinkHelper.generated_by_sheets}).delete() - print(f"Num Ref Links {len(all_ref_links)}") - print(f"Num Intra Links {len(related_links)}") - print(f"Num to Update {len(list(filter(lambda x: getattr(x, '_id', False), all_ref_links + related_links)))}") - print(f"Num to Insert {len(list(filter(lambda x: not getattr(x, '_id', False), all_ref_links + related_links)))}") + all_ref_links = update_ref_topic_link_orders(sheet_source_links.array(), sheet_topic_links.array()) db.topic_links.bulk_write([ UpdateOne({"_id": l._id}, {"$set": {"order": l.order}}) - if getattr(l, "_id", False) else - InsertOne(l.contents(for_db=True)) for l in (all_ref_links + related_links) ]) - add_num_sources_to_topics() - make_titles_unique() - def set_all_slugs_to_primary_title(): # reset all slugs to their primary titles, if they have drifted away diff --git a/sefaria/model/abstract.py b/sefaria/model/abstract.py index 2057c0e91a..1195b086be 100644 --- a/sefaria/model/abstract.py +++ b/sefaria/model/abstract.py @@ -244,16 +244,12 @@ def _validate(self): " not in " + ",".join(self.required_attrs) + " or " + ",".join(self.optional_attrs)) return False """ - for attr, schema in self.attr_schemas.items(): - v = Validator(schema) - try: - value = getattr(self, attr) - if not v.validate(value): - raise InputError(v.errors) - except AttributeError: - # not checking here if value exists, that is done above. - # assumption is if value doesn't exist, it's optional - pass + schema = self.attr_schemas + for key in schema: + schema[key]['allow_unknown'] = schema[key].get('allow_unknown', False) # allow unknowns only in the root + v = Validator(schema, allow_unknown=True) + if not v.validate(self._saveable_attrs()): + raise InputError(v.errors) return True def _normalize(self): diff --git a/sefaria/model/lexicon.py b/sefaria/model/lexicon.py index e8d6595150..d54d168dd4 100644 --- a/sefaria/model/lexicon.py +++ b/sefaria/model/lexicon.py @@ -61,7 +61,8 @@ class Lexicon(abst.AbstractMongoRecord): 'index_title', # The title of the Index record that corresponds to this Lexicon 'version_title', # The title of the Version record that corresponds to this Lexicon 'version_lang', # The language of the Version record that corresponds to this Lexicon - 'should_autocomplete' # enables search box + 'should_autocomplete', # enables search box + 'needsRefsWrapping' ] def word_count(self): @@ -357,6 +358,23 @@ def as_strings(self, with_headword=True): return ['
'.join(strings)] +class KovetzYesodotEntry(DictionaryEntry): + required_attrs = DictionaryEntry.required_attrs + ["content", "rid"] + + def headword_string(self): + return f'{self.headword}' + + def as_strings(self, with_headword=True): + strings = [] + if with_headword: + strings.append(self.headword_string()) + for key, value in self.content.items(): + if key != 'reference': + strings.append(f'
{key}') + strings += value + return ['
'.join(strings)] + + class LexiconEntrySubClassMapping(object): lexicon_class_map = { 'BDB Augmented Strong': StrongsDictionaryEntry, @@ -367,7 +385,8 @@ class LexiconEntrySubClassMapping(object): 'Sefer HaShorashim': HebrewDictionaryEntry, 'Animadversions by Elias Levita on Sefer HaShorashim': HebrewDictionaryEntry, 'BDB Dictionary': BDBEntry, - 'BDB Aramaic Dictionary': BDBEntry + 'BDB Aramaic Dictionary': BDBEntry, + 'Kovetz Yesodot VaChakirot': KovetzYesodotEntry, } @classmethod diff --git a/sefaria/model/portal.py b/sefaria/model/portal.py index 36984ceaad..c10e6dde34 100644 --- a/sefaria/model/portal.py +++ b/sefaria/model/portal.py @@ -20,100 +20,51 @@ class Portal(abst.SluggedAbstractMongoRecord): "organization" ] attr_schemas = { - "about": { - "title": { - "type": "dict", - "required": True, - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - "title_url": {"type": "string"}, - "image_uri": {"type": "string"}, - "image_caption": { - "type": "dict", - "schema": { - "en": {"type": "string"}, - "he": {"type": "string"} - } - }, - "description": { - "type": "dict", - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - }, - "mobile": { - "title": { - "type": "dict", - "required": True, - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - "description": { - "type": "dict", - "schema": { - "en": {"type": "string"}, - "he": {"type": "string"} - } - }, - "android_link": {"type": "string"}, - "ios_link": {"type": "string"} - }, - "organization": { - "title": { - "type": "dict", - "required": True, - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - "description": { - "type": "dict", - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - }, - "newsletter": { - "title": { - "type": "dict", - "required": True, - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - "description": { - "type": "dict", - "schema": { - "en": {"type": "string", "required": True}, - "he": {"type": "string", "required": True} - } - }, - "title_url": {"type": "string"}, - "api_schema": { - "type": "dict", - "schema": { - "http_method": {"type": "string", "required": True}, - "payload": { - "type": "dict", - "schema": { - "first_name_key": {"type": "string"}, - "last_name_key": {"type": "string"}, - "email_key": {"type": "string"} - } - }, - } - } - } + 'about': {'type': 'dict', + 'schema': {'title': {'type': 'dict', + 'required': True, + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}, + 'title_url': {'type': 'string'}, + 'image_uri': {'type': 'string'}, + 'image_caption': {'type': 'dict', + 'schema': {'en': {'type': 'string'}, 'he': {'type': 'string'}}}, + 'description': {'type': 'dict', + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}}}, + 'mobile': {'type': 'dict', + 'schema': {'title': {'type': 'dict', + 'required': True, + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}, + 'description': {'type': 'dict', + 'schema': {'en': {'type': 'string'}, 'he': {'type': 'string'}}}, + 'android_link': {'type': 'string'}, + 'ios_link': {'type': 'string'}}}, + 'organization': {'type': 'dict', + 'schema': {'title': {'type': 'dict', + 'required': True, + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}, + 'description': {'type': 'dict', + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}}}, + 'newsletter': {'type': 'dict', + 'schema': {'title': {'type': 'dict', + 'required': True, + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}, + 'description': {'type': 'dict', + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}, + 'title_url': {'type': 'string'}, + 'api_schema': {'type': 'dict', + 'schema': {'http_method': {'type': 'string', 'required': True}, + 'payload': {'type': 'dict', + 'schema': { + 'first_name_key': {'type': 'string'}, + 'last_name_key': {'type': 'string'}, + 'email_key': {'type': 'string'}}}}}}} } def _validate(self): diff --git a/sefaria/model/schema.py b/sefaria/model/schema.py index 77cb1f4f07..ca075ab46c 100644 --- a/sefaria/model/schema.py +++ b/sefaria/model/schema.py @@ -2646,7 +2646,7 @@ class AddressPerek(AddressInteger): } section_patterns = { "en": r"""(?:(?:[Cc]h(apters?|\.)|[Pp]erek|s\.)?\s*)""", # the internal ? is a hack to allow a non match, even if 'strict' - "he": fr"""(?:\u05d1?{AddressType.reish_samekh_reg}\u05e4((?:"|\u05f4|''|'\s)|(?=[\u05d0-\u05ea]+(?:"|\u05f4|''|'\s))) # Peh (for 'perek') maybe followed by a quote of some sort OR lookahead for some letters followed by a quote (e.g. פי״א for chapter 11) + "he": fr"""(?:\u05d1?{AddressType.reish_samekh_reg}\u05e4((?:"|\u05f4|''|'\s|\s)|(?=[\u05d0-\u05ea]+(?:"|\u05f4|''|'\s))) # Peh (for 'perek') maybe followed by a quote of some sort OR lookahead for some letters followed by a quote (e.g. פי״א for chapter 11) |\u05e4\u05bc?\u05b6?\u05e8\u05b6?\u05e7(?:\u05d9\u05b4?\u05dd)?\s* # or 'perek(ym)' spelled out, followed by space )""" } diff --git a/sefaria/model/text.py b/sefaria/model/text.py index 1255a7a6c3..b8b254a74d 100644 --- a/sefaria/model/text.py +++ b/sefaria/model/text.py @@ -2295,6 +2295,9 @@ def version(self): def version_ids(self): return [self._versions[0]._id] if self._versions else [] + def has_manually_wrapped_refs(self): + return not getattr(self._oref.index_node.parent.lexicon, 'needsRefsWrapping', False) + # This was built as a bridge between the object model and existing front end code, so has some hallmarks of that legacy. class TextFamily(object): diff --git a/sefaria/model/topic.py b/sefaria/model/topic.py index b965dbcaf5..a36ae1b44e 100644 --- a/sefaria/model/topic.py +++ b/sefaria/model/topic.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Union, Optional from . import abstract as abst from .schema import AbstractTitledObject, TitleGroup @@ -15,6 +16,11 @@ logger = structlog.get_logger(__name__) +class Pool(Enum): + TEXTUAL = "textual" + SHEETS = "sheets" + + class Topic(abst.SluggedAbstractMongoRecord, AbstractTitledObject): collection = 'topics' history_noun = 'topic' @@ -39,7 +45,7 @@ class Topic(abst.SluggedAbstractMongoRecord, AbstractTitledObject): 'categoryDescription', # dictionary, keys are 2-letter language codes 'isTopLevelDisplay', 'displayOrder', - 'numSources', + 'numSources', # total number of refLinks, to texts and sheets. 'shouldDisplay', 'parasha', # name of parsha as it appears in `parshiot` collection 'ref', # dictionary for topics with refs associated with them (e.g. parashah) containing strings `en`, `he`, and `url`. @@ -49,28 +55,27 @@ class Topic(abst.SluggedAbstractMongoRecord, AbstractTitledObject): "data_source", #any topic edited manually should display automatically in the TOC and this flag ensures this 'image', "portal_slug", # slug to relevant Portal object + 'pools', # list of strings, any of them represents a pool that this topic is member of ] + allowed_pools = [pool.value for pool in Pool] + ['torahtab'] + attr_schemas = { "image": { - "image_uri": { - "type": "string", - "required": True, - "regex": "^https://storage\.googleapis\.com/img\.sefaria\.org/topics/.*?" - }, - "image_caption": { - "type": "dict", - "required": True, - "schema": { - "en": { - "type": "string", - "required": True - }, - "he": { - "type": "string", - "required": True - } - } + 'type': 'dict', + 'schema': {'image_uri': {'type': 'string', + 'required': True, + 'regex': '^https://storage\\.googleapis\\.com/img\\.sefaria\\.org/topics/.*?'}, + 'image_caption': {'type': 'dict', + 'required': True, + 'schema': {'en': {'type': 'string', 'required': True}, + 'he': {'type': 'string', 'required': True}}}} + }, + 'pools': { + 'type': 'list', + 'schema': { + 'type': 'string', + 'allowed': allowed_pools } } } @@ -114,6 +119,10 @@ def _normalize(self): displays_under_link = IntraTopicLink().load({"fromTopic": slug, "linkType": "displays-under"}) if getattr(displays_under_link, "toTopic", "") == "authors": self.subclass = "author" + if self.get_pools(): + self.pools = sorted(set(self.get_pools())) + elif hasattr(self, 'pools'): + delattr(self, 'pools') def _sanitize(self): super()._sanitize() @@ -123,6 +132,20 @@ def _sanitize(self): p[k] = bleach.clean(v, tags=[], strip=True) setattr(self, attr, p) + def get_pools(self): + return getattr(self, 'pools', []) + + def has_pool(self, pool): + return pool in self.get_pools() + + def add_pool(self, pool): #does not save! + self.pools = self.get_pools() + self.pools.append(pool) + + def remove_pool(self, pool): #does not save! + pools = self.get_pools() + pools.remove(pool) + def set_titles(self, titles): self.title_group = TitleGroup(titles) @@ -362,9 +385,16 @@ def link_set(self, _class='intraTopic', query_kwargs: dict = None, **kwargs): kwargs['record_kwargs'] = {'context_slug': self.slug} return TopicLinkSetHelper.find(intra_link_query, **kwargs) + def get_ref_links(self, is_sheet, query_kwargs=None, **kwargs): + query_kwargs = query_kwargs or {} + query_kwargs['is_sheet'] = is_sheet + return self.link_set('refTopic', query_kwargs, **kwargs) + def contents(self, **kwargs): mini = kwargs.get('minify', False) d = {'slug': self.slug} if mini else super(Topic, self).contents(**kwargs) + if kwargs.get('remove_pools', True): + d.pop('pools', None) d['primaryTitle'] = {} for lang in ('en', 'he'): d['primaryTitle'][lang] = self.get_primary_title(lang=lang, with_disambiguation=kwargs.get('with_disambiguation', True)) @@ -425,6 +455,19 @@ def __str__(self): def __repr__(self): return "{}.init('{}')".format(self.__class__.__name__, self.slug) + def update_after_link_change(self, pool): + """ + updating the pools 'sheets' or 'textual' according to the existence of links and the numSources + :param pool: 'sheets' or 'textual' + """ + links = self.get_ref_links(pool == Pool.SHEETS.value) + if self.has_pool(pool) and not links: + self.remove_pool(pool) + elif not self.has_pool(pool) and links: + self.add_pool(pool) + self.numSources = self.link_set('refTopic').count() + self.save() + class PersonTopic(Topic): """ @@ -814,6 +857,24 @@ def set_description(self, lang, title, prompt): self.descriptions = d return self + def get_related_pool(self): + return Pool.SHEETS.value if self.is_sheet else Pool.TEXTUAL.value + + def get_topic(self): + return Topic().load({'slug': self.toTopic}) + + def save(self, override_dependencies=False): + super(RefTopicLink, self).save(override_dependencies) + topic = self.get_topic() + topic.update_after_link_change(self.get_related_pool()) + + def delete(self, force=False, override_dependencies=False): + topic = self.get_topic() + pool = self.get_related_pool() + super(RefTopicLink, self).delete(force, override_dependencies) + if topic: + topic.update_after_link_change(pool) + def _sanitize(self): super()._sanitize() for lang, d in getattr(self, "descriptions", {}).items(): diff --git a/sefaria/sheets.py b/sefaria/sheets.py index 7005b5924a..13a3dcde2b 100755 --- a/sefaria/sheets.py +++ b/sefaria/sheets.py @@ -784,6 +784,22 @@ def get_top_sheets(limit=3): query = {"status": "public", "views": {"$gte": 100}} return sheet_list(query=query, limit=limit) +def annotate_sheets_with_collections(sheets): + """ + Annotate a list of `sheets` with a list of public collections that the sheet appears in. + """ + ids = list({int(s['id']) for s in sheets}) + collections = CollectionSet({'sheets': {'$in': ids}, 'listed': True}, hint="sheets_listed") #Return every public collection that has a sheet in `ids` + + sheet_id_to_collections = defaultdict(list) + for collection in collections: + for sheet_id in collection.sheets: + sheet_id_to_collections[sheet_id].append(collection) + + for sheet in sheets: + collections = sheet_id_to_collections[int(sheet["id"])] + sheet["collections"] = [{'name': collection.name, 'slug': collection.slug} for collection in collections] + return sheets def get_sheets_for_ref(tref, uid=None, in_collection=None): """ @@ -806,8 +822,10 @@ def get_sheets_for_ref(tref, uid=None, in_collection=None): sheets_ids = [sheet for sublist in sheets_list for sheet in sublist] query["id"] = {"$in": sheets_ids} - sheetsObj = db.sheets.find(query, - {"id": 1, "title": 1, "owner": 1, "viaOwner":1, "via":1, "dateCreated": 1, "includedRefs": 1, "expandedRefs": 1, "views": 1, "topics": 1, "status": 1, "summary":1, "attribution":1, "assigner_id":1, "likes":1, "displayedCollection":1, "options":1}).sort([["views", -1]]) + projection = {"id": 1, "title": 1, "owner": 1, "viaOwner":1, "via":1, "dateCreated": 1, "includedRefs": 1, "expandedRefs": 1, + "views": 1, "topics": 1, "status": 1, "summary":1, "attribution":1, "assigner_id":1, "likes":1, + "displayedCollection":1, "options":1} + sheetsObj = db.sheets.find(query, projection).sort([["views", -1]]) sheetsObj.hint("expandedRefs_1") sheets = [s for s in sheetsObj] user_ids = list({s["owner"] for s in sheets}) @@ -873,8 +891,8 @@ def get_sheets_for_ref(tref, uid=None, in_collection=None): "is_featured": sheet.get("is_featured", False), "category": "Sheets", # ditto "type": "sheet", # ditto + "dateCreated": sheet.get("dateCreated", None) } - results.append(sheet_data) return results diff --git a/sefaria/urls.py b/sefaria/urls.py index 7b084882c8..b3f5bf7940 100644 --- a/sefaria/urls.py +++ b/sefaria/urls.py @@ -6,6 +6,8 @@ from django.contrib import admin from django.http import HttpResponseRedirect import django.contrib.auth.views as django_auth_views + +import sourcesheets from sefaria.forms import SefariaPasswordResetForm, SefariaSetPasswordForm, SefariaLoginForm from sefaria.settings import DOWN_FOR_MAINTENANCE, STATIC_URL, ADMIN_PATH @@ -32,6 +34,7 @@ url(r'^texts/recent/?$', reader_views.old_recent_redirect), url(r'^texts/(?P.+)?$', reader_views.texts_category_list), url(r'^search/?$', reader_views.search), + url(r'sheets/sheets-with-ref/(?P.+)$', sourcesheets.views.sheets_with_ref), url(r'^search-autocomplete-redirecter/?$', reader_views.search_autocomplete_redirecter), url(r'^calendars/?$', reader_views.calendars), url(r'^collections/?$', reader_views.public_collections), diff --git a/sites/sefaria/urls.py b/sites/sefaria/urls.py index d8145540b1..ff2c952a91 100644 --- a/sites/sefaria/urls.py +++ b/sites/sefaria/urls.py @@ -48,6 +48,7 @@ "cloudflare_site_is_down_en", "cloudflare_site_is_down_he", "team", + "products", "link-to-annual-report", 'mobile-about-menu', "updates", diff --git a/sourcesheets/views.py b/sourcesheets/views.py index 6a103a9eea..fc7bc6ac94 100644 --- a/sourcesheets/views.py +++ b/sourcesheets/views.py @@ -17,6 +17,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt, csrf_protect from django.contrib.auth.decorators import login_required +from django.utils.translation import ugettext as _ # noinspection PyUnresolvedReferences from django.contrib.auth.models import User @@ -35,7 +36,7 @@ from sefaria.system.decorators import catch_error_as_json from sefaria.utils.util import strip_tags -from reader.views import render_template, catchall +from reader.views import render_template, catchall, get_search_params from sefaria.sheets import clean_source, bleach_text from bs4 import BeautifulSoup @@ -45,6 +46,7 @@ from sefaria.gauth.decorators import gauth_required +from reader.views import menu_page def annotate_user_links(sources): """ @@ -1027,8 +1029,29 @@ def sheets_by_ref_api(request, ref): """ API to get public sheets by ref. """ - return jsonResponse(get_sheets_for_ref(ref)) + include_collections = bool(int(request.GET.get("include_collections", 0))) + sheets = get_sheets_for_ref(ref) + if include_collections: + sheets = annotate_sheets_with_collections(sheets) + return jsonResponse(sheets) +def sheets_with_ref(request, tref): + """ + Accepts tref as a string which is expected to be in the format of a ref or refs separated by commas, indicating a range. + """ + search_params = get_search_params(request.GET) + props={ + "initialSearchType": "sheet", + "initialTextSearchField": search_params["textField"], + "initialSheetSearchFilters": search_params["sheetFilters"], + "initialSheetSearchFilterAggTypes": search_params["sheetFilterAggTypes"], + "initialSheetSearchSortType": search_params["sheetSort"] + } + he_tref = Ref(tref).he_normal() + normal_ref = tref if request.interfaceLang == "english" else he_tref + title = _(f"Sheets with ")+normal_ref+_(" on Sefaria") + props["sheetsWithRef"] = {"en": tref, "he": he_tref} + return menu_page(request, page="sheetsWithRef", title=title, props=props) def get_aliyot_by_parasha_api(request, parasha): response = {"ref":[]}; diff --git a/static/css/s2.css b/static/css/s2.css index 16fca4bb08..0c87b6568e 100644 --- a/static/css/s2.css +++ b/static/css/s2.css @@ -971,7 +971,7 @@ div.interfaceLinks-row a { } } @media screen and (max-width: 580px) { - .header .interfaceLinks, + .header .interfaceLinks, .header .dropdownLinks, .accountLinks .help, .header .notifications { @@ -1491,7 +1491,7 @@ div.interfaceLinks-row a { text-align: right; unicode-bidi: initial; } -} + .interface-hebrew .readerPanel .en { direction: ltr; } @@ -10321,17 +10321,21 @@ span.purim-emoji img{ .interface-english .spacer:only-of-type.empty:before, .interface-english .spacer:only-of-type.empty:before { content: "Write something... "; + margin-inline-start: 50px; } .interface-hebrew .sheetItem:only-of-type.empty .SheetOutsideText:before, .interface-hebrew .spacer:only-of-type.empty:before, .interface-hebrew .spacer:only-of-type.empty:before { content: "לכתוב משהו..."; + margin-inline-start: 50px; } .editorAddInterface { position: relative; pointer-events:none; background-color: transparent; + margin-inline-start: 50px; } + .editorAddInterface:before { content: ""; margin-inline-start:-46px; @@ -10351,6 +10355,34 @@ span.purim-emoji img{ box-sizing: border-box; box-shadow: 0px 1px 3px 0px #00000040; } +.editorAddLineButton { + position: relative; + background-color: transparent; + margin-inline-start: 50px; +} +.hidden.editorAddLineButton::before { + display: none; +} + +.editorAddLineButton:before { + content: ""; + margin-inline-start:-46px; + position: absolute; + width: 30px; + height: 30px; + transform: rotate(45deg); + background-color: white; + background-image: url("/static/icons/heavy-x-dark.svg"); + border: 1px solid var(--light-grey); + background-size: 14px; + border-radius: 50%; + /*pointer-events:auto;*/ + cursor: pointer; + background-repeat: no-repeat; + background-position: center; + box-sizing: border-box; + box-shadow: 0px 1px 3px 0px #00000040; +} .editorAddInterface:hover::before { background-color: var(--lighter-grey); @@ -10374,6 +10406,10 @@ background-color: white; pointer-events: none; display: inline-block; } +.hidden.editorAddInterface::before { + display: none; +} + .addInterfaceInput .textPreview { border-inline-start: 4px solid; @@ -13376,6 +13412,219 @@ span.ref-link-color-3 {color: blue} display: block; } +.productsFlexWrapper { + display: flex; + flex-direction: column; +} + +.product { + max-width: 600px; +} + +#productsPageContent { + margin-inline-start: 70px; + margin-top: 165px; + margin-bottom: 165px; +} + +.productInner { + display: flex; + align-items: right; + flex-direction: row; +} + +.productInner img { + max-width: 100%; + max-height: 116px; + height: auto; + display: block; + margin-inline-end: 3%; + padding-bottom: 20px; +} + +.productInner .productsDesc { + font: var(--english-sans-serif-font-family); + color: var(--dark-grey); + font-size: 16px; +} + +.productImgWrapper { + flex: 0 0 auto; +} + +.productDescWrapper { + flex: 1 1 auto; + padding-left: 20px; + padding-right: 20px; +} + +.productsDesc p { + margin-top: 0; +} + +.productsHeader { + display: flex; + justify-content: space-between; + margin-top: 10%; + margin-bottom: 2%; + padding-bottom: 2%; + border-bottom: 1px solid var(--light-grey); +} + +.productsTitleAndLabel { + flex: auto; +} + +.productsHeader .productsTitle { + font: var(--english-sans-serif-font-family); + color: var(--dark-grey); + font-size: 22px; + font-weight: 500px; +} + +.productsHeader .productsTypeLabel { + font: var(--english-sans-serif-font-family); + font-size: 14px; + font-weight: 400; + line-height: 18px; + background-color: var(--lighter-grey); + border-radius: 6px; + padding: 0.01px 5px; + color: var(--dark-grey); + margin-inline-start: 3%; +} + +.productsHeader .cta { + display: flex; + align-items: center; +} + +.productsHeader .cta .productsCTA { + color: var(--commentary-blue); + border-width: 10px; + margin-inline-end: 10px; +} + + +.productsHeader .cta .productsCTAIcon { + height: 12px; + top: 646px; + left: 610px; + margin: 0 5px; + vertical-align: baseline; + /** This uses the filter to recolor SVGs in a single line to commentary blue. + To determine the appropriate parameters, see a filter color picker such as https://angel-rs.github.io/css-color-filter-generator/ */ + filter: brightness(0) saturate(100%) invert(52%) sepia(17%) saturate(6763%) hue-rotate(200deg) brightness(78%) contrast(77%); + +} + +.productsTitle { + font-family: var(--english-sans-serif-font-family); + font-size: 22px; + font-weight: 500; + line-height: 25.78px; + text-align: left; + color: var(--dark-grey); +} + +.productsCTA::after { + content: " ›"; + color: var(--commentary-blue); +} + +.productsDevBox { + background: var(--lighter-grey); + color: var(--dark-grey); + font-family: var(--english-sans-serif-font-family); + font-size: 16px; + font-weight: 400; + line-height: 18.75px; + display: flex; + align-items: flex-start; + flex-direction: column; + padding: 3%; + margin-top: 10%; + max-width: 600px; +} + +.productsDevBox .productsDevHeader { + font-family: var(--english-sans-serif-font-family); + font-size: 22px; + font-weight: 500; + line-height: 25.78px; + padding-bottom: 2%; + +} + +.productsDevBox a { + color: var(--commentary-blue) !important; + +} + +.productsDevBox p { + margin-top: 0; +} + +.productsDevBox a::after { + content: " ›"; + color: var(--commentary-blue); +} + +@media (max-width: 480px) { + + .productImgWrapper { + display: block; + margin-left: auto; + margin-right: auto; + } + + #productsPageContent { + margin: 100px 10px 0px 10px;; + } + + .productsHeader { + flex-direction: column; + align-items: flex-start; + padding-top: 5px; + padding-bottom: 5px; + } + + .productsTitleAndLabel { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-top: 5px; + padding-bottom: 5px; + } + + .productsTypeLabel { + padding: 1px 5px !important; + margin-top: 3px; + margin-left: 0px !important; + } + + .cta { + flex-direction: column; + align-items: flex-start !important; + } + + .cta a { + padding-top: 3px; + padding-bottom: 3px; + } + + .productInner { + flex-direction: column; + } + + .productDescWrapper{ + padding-left: 0px; + padding-right: 0px; + } +} + + + .image-in-text-title { margin: auto; /* English System Small */ diff --git a/static/css/static.css b/static/css/static.css index c854856f2b..1459cd84f3 100644 --- a/static/css/static.css +++ b/static/css/static.css @@ -3435,7 +3435,8 @@ form.globalUpdateForm + div.notificationsList { display: none; } #about.selected, #team.selected, #jobs.selected, #updates.selected, #testimonials.selected, -#supporters.selected, #metrics.selected, #annualreport.selected, #terms.selected, #privacy.selected { +#supporters.selected, #metrics.selected, #annualreport.selected, #terms.selected, +#privacy.selected, #products.selected { background-color: var(--lighter-grey); color: var(--selected-option); border-radius: 6px; diff --git a/static/icons/products.svg b/static/icons/products.svg new file mode 100644 index 0000000000..1f9dc0cfd5 --- /dev/null +++ b/static/icons/products.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/js/AddToSourceSheet.jsx b/static/js/AddToSourceSheet.jsx index 8efaccc60c..3b456e1a11 100644 --- a/static/js/AddToSourceSheet.jsx +++ b/static/js/AddToSourceSheet.jsx @@ -12,6 +12,7 @@ import Component from 'react-class'; import sanitizeHtml from 'sanitize-html'; import { SignUpModalKind } from './sefaria/signupModalContent'; import { GDocAdvertBox } from './Promotions'; +import * as sheetsUtils from './sefaria/sheetsUtils' @@ -89,7 +90,34 @@ class AddToSourceSheetBox extends Component { }, this.confirmAdd); } } - addToSourceSheet() { + + //return the initial index of the suffix of string1 which also constitutes a prefix for string2 + longestSuffixPrefixIndex(string1, string2) { + let longestSuffixIndex = 0; + for (let i = 0; i < string1.length; i++){ + let suffix = string1.slice(i); + if (string2.startsWith(suffix)) { + longestSuffixIndex = i; + } + } + return longestSuffixIndex; + } + //return the final index of the prefix of string1 which also constitutes a suffix for string2 + longestPrefixSuffixIndex(string1, string2) { + let longestPrefixIndex = 0; + for (let i = 0; i < string1.length; i++) { + let prefix = string1.slice(0, i + 1); + if (string2.endsWith(prefix)) { + longestPrefixIndex = i + 1; + } + } + return longestPrefixIndex; + } + + normalize(text){ + return(text.replaceAll(/()+/g, ' ').replace(/\u2009/g, ' ').replace(/<[^>]*>/g, '')); + } + async addToSourceSheet() { if (!Sefaria._uid) { this.props.toggleSignUpModal(SignUpModalKind.AddToSheet); } @@ -111,15 +139,40 @@ class AddToSourceSheetBox extends Component { } } else if (this.props.srefs) { //regular use - this is currently the case when the component is loaded in the sidepanel or in the modal component via profiles and notes pages source.refs = this.props.srefs; + + + const { en, he } = this.props.currVersions ? this.props.currVersions : {"en": null, "he": null}; //the text we are adding may be non-default version if (he) { source["version-he"] = he; } if (en) { source["version-en"] = en; } // If something is highlighted and main panel language is not bilingual: // Use passed in language to determine which version this highlight covers. - var selectedWords = this.props.selectedWords; //if there was highlighted single panel + let selectedWords = this.props.selectedWords; //if there was highlighted single panel if (selectedWords && language != "bilingual") { - source[language.slice(0,2)] = selectedWords; + let lan = language.slice(0,2); + let segments = await sheetsUtils.getSegmentObjs(source.refs); + selectedWords = this.normalize(selectedWords); + segments = segments.map(segment => ({ + ...segment, + [lan]: this.normalize(segment[lan]) + })); + for (let iSegment = 0; iSegment < segments.length; iSegment++) { + const segment = segments[iSegment]; + if (iSegment == 0){ + let criticalIndex = this.longestSuffixPrefixIndex(segment[lan], selectedWords); + const ellipse = criticalIndex == 0 ? "" : "..."; + segment[lan] = ellipse + segment[lan].slice(criticalIndex); + } + else if (iSegment == segments.length-1){ + let criticalIndex = this.longestPrefixSuffixIndex(segment[lan], selectedWords); + const ellipse = criticalIndex == segment[lan].length-1 ? "" : "..."; + const chunk = segment[lan].slice(0, criticalIndex) + segment[lan] = chunk + ellipse; + } + } + + source[lan] = sheetsUtils.segmentsToSourceText(segments, lan); } } if (this.checkContentForImages(source.refs)) { diff --git a/static/js/CommunityPage.jsx b/static/js/CommunityPage.jsx index 8510b13093..d0ad184441 100644 --- a/static/js/CommunityPage.jsx +++ b/static/js/CommunityPage.jsx @@ -20,7 +20,6 @@ const CommunityPage = ({multiPanel, toggleSignUpModal, initialWidth}) => { const [dataLoaded, setDataLoaded] = useState(!!Sefaria.community); const sidebarModules = [ - {type: "JoinTheConversation"}, {type: dataLoaded ? "WhoToFollow" : null, props: {toggleSignUpModal}}, {type: "Promo"}, {type: "ExploreCollections"}, @@ -101,13 +100,7 @@ const RecentlyPublished = ({multiPanel, toggleSignUpModal}) => { const recentSheetsContent = !recentSheets ? [] : recentSheets.map(s => ); - const joinTheConversation = ( -
- -
- ); if (recentSheets) { - recentSheetsContent.splice(6, 0, joinTheConversation); recentSheetsContent.push( Load More diff --git a/static/js/ConnectionsPanel.jsx b/static/js/ConnectionsPanel.jsx index b874731285..c040af9889 100644 --- a/static/js/ConnectionsPanel.jsx +++ b/static/js/ConnectionsPanel.jsx @@ -385,6 +385,7 @@ class ConnectionsPanel extends Component { null } @@ -457,27 +458,6 @@ class ConnectionsPanel extends Component { filterRef={this.props.filterRef} />); - } else if (this.props.mode === "Sheets") { - const connectedSheet = this.props.nodeRef ? this.props.nodeRef.split(".")[0] : null; - content = (
- {this.props.srefs[0].indexOf("Sheet") === -1 ? - - : null - } - {this.props.srefs[0].indexOf("Sheet") === -1 ? - : null - } -
); } else if (this.props.mode === "Add To Sheet") { let refForSheet, versionsForSheet, selectedWordsForSheet, nodeRef; // add source from connections @@ -750,12 +730,16 @@ ConnectionsPanel.propTypes = { backButtonSettings: PropTypes.object, }; +const createSheetsWithRefURL = (srefs) => { + const normalizedRef = Sefaria.normRef(srefs); + window.open(`${Sefaria.apiHost}/sheets/sheets-with-ref/${normalizedRef}`); +} -const ResourcesList = ({ masterPanelMode, setConnectionsMode, counts }) => { +const ResourcesList = ({ srefs, setConnectionsMode, counts }) => { // A list of Resources in addition to connection return (
- setConnectionsMode("Sheets")} /> + createSheetsWithRefURL(srefs)} /> setConnectionsMode("WebPages")} /> setConnectionsMode("Topics")} alwaysShow={Sefaria.is_moderator} /> setConnectionsMode("manuscripts")} /> diff --git a/static/js/Editor.jsx b/static/js/Editor.jsx index 3a8c4b5a21..027d5c5ec8 100644 --- a/static/js/Editor.jsx +++ b/static/js/Editor.jsx @@ -5,6 +5,8 @@ import {Editor, createEditor, Range, Node, Transforms, Path, Text, Point, Elemen import {Slate, Editable, ReactEditor, withReact, useSlate, useSelected, useFocused} from 'slate-react' import isHotkey from 'is-hotkey' import Sefaria from './sefaria/sefaria'; +import * as sheetsUtils from './sefaria/sheetsUtils' + import { SheetMetaDataBox, @@ -147,6 +149,59 @@ const format_to_html_lookup = format_tag_pairs.reduce((obj, item) => { return {node: bottom, path: bottomPath} }; +const isMultiNodeSelection = (editor) => { + if (!editor.selection) {return false} + + const [start, end] = Range.edges(editor.selection); + + const startPath = start.path; + const endPath = end.path; + + // If the start and end paths are different, it means multiple nodes are selected + return !Path.equals(startPath, endPath); +}; +const moveAnchorToEndOfClosestParagraph = (editor) => { + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const { anchor } = selection; + const [closestParagraphNode, closestParagraphPath] = Editor.above(editor, { + at: anchor.path, + match: (n) => n.type === 'paragraph', + }) || []; + + if (closestParagraphNode) { + const endPoint = Editor.end(editor, closestParagraphPath); + + Transforms.select(editor, { + anchor: endPoint, + focus: endPoint, + }); + } + } +}; +const moveAnchorToEndOfCurrentNode = (editor) => { + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const { anchor } = selection; + const node = Editor.node(editor, anchor); + + if (node) { + const [, path] = node; + const endPoint = Editor.end(editor, path); + + Transforms.select(editor, { + anchor: endPoint, + focus: endPoint + }); + } + } +}; +const insertNewLine = (editor) => { + moveAnchorToEndOfClosestParagraph(editor); + editor.insertBreak(); +} export const deserialize = el => { if (el.nodeType === 3) { @@ -336,15 +391,15 @@ export const serialize = (content) => { const replaceDivineNames = (str, divineName) => { // Regexes for identifying divine names with or without nikkud / trop // Currently ignores אֵל & צְבָאוֹת & שדי - const divineRE = /([\s.,\u05BE;:'"\-]|^)([ו]?[\u0591-\u05C7]*[משהוכלב]?[\u0591-\u05C7]*)(י[\u0591-\u05C7]*ה[\u0591-\u05C7]*ו[\u0591-\u05C7]*ה[\u0591-\u05C2\u05C4-\u05C7]*|יְיָ|יי|יקוק|ה\'|ה׳)(?=[/(/[ { const Element = (props) => { const { attributes, children, element } = props; + const editor = useSlate(); + + const sheetItemClasses = { sheetItem: 1, empty: !(Node.string(element)), @@ -1180,11 +1238,32 @@ const Element = (props) => {
{children}
) case 'paragraph': + const selected = useSelected(); + + const addNewLineClasses = { + hidden: isMultiNodeSelection(editor) || !selected, + editorAddLineButton: 1, + }; + const handleClick = (event, editor) => { + // a way to check if the click was on the 'pseudo' ::before element or on the actual div + //this relies on the event bubbling mechanism doing sth iffy, we should findd a more deterministic way to implement this check + if (event.target.matches('.editorAddLineButton')) { //if click was on ::before + insertNewLine(editor); + } else { + return; + } + }; + const pClasses = {center: element["text-align"] == "center" }; return ( -
- {element.loading ?
: null} - {children} +
handleClick(event, editor)} + > +
+ {element.loading ?
: null} + {children} +
); case 'bulleted-list': @@ -1866,25 +1945,7 @@ const insertMedia = (editor, mediaUrl) => { Transforms.move(editor); } - -function placed_segment_mapper(lang, segmented, includeNumbers, s) { - if (!s[lang]) {return ""} - - let numStr = ""; - if (includeNumbers) { - const num = (lang=="he") ? Sefaria.hebrew.encodeHebrewNumeral(s.number) : s.number; - numStr = "(" + num + ") "; - } - let str = "" + numStr + s[lang] + " "; - if (segmented) { - str = "

" + str + "

"; - } - str = str.replace(/()+/g, ' ') - return str; -} - - -const insertSource = (editor, ref) => { +const insertSource = async (editor, ref) => { const path = editor.selection.anchor.path; Transforms.setNodes(editor, { loading: true }, {at: path}); @@ -1892,47 +1953,39 @@ const insertSource = (editor, ref) => { const nodeAbove = getNodeAbove(path, editor) const nodeBelow = getNodeBelow(path, editor) - Sefaria.getText(ref, {stripItags: 1}).then(text => { - let segments = Sefaria.makeSegments(text, false); - segments = Sefaria.stripImagesFromSegments(segments); - - let includeNumbers = $.inArray("Talmud", text.categories) == -1; - includeNumbers = text.indexTitle === "Pesach Haggadah" ? false : includeNumbers; - const segmented = !(text.categories[0] in {"Tanakh": 1, "Talmud": 1}); - - const enText = segments.map(placed_segment_mapper.bind(this, "en", segmented, includeNumbers)) - .filter(Boolean) - .join(""); - const heText = segments.map(placed_segment_mapper.bind(this, "he", segmented, includeNumbers)) - .filter(Boolean) - .join(""); - - let fragment = [{ - type: "SheetSource", - node: editor.children[0].nextNode, - ref: text.ref, - heRef: text.heRef, - heText: parseSheetItemHTML(heText), - enText: parseSheetItemHTML(enText), - title: null, - children: [ - {text: ""}, - ] - }]; - - if (!(nodeBelow.node && (nodeBelow.node.type == "SheetOutsideText" || nodeBelow.node.type == "paragraph" ) )) { - fragment.push({type: 'spacer', children: [{text: ""}]}) - } - Transforms.setNodes(editor, { loading: false }, { at: path }); - addItemToSheet(editor, fragment); - checkAndFixDuplicateSheetNodeNumbers(editor) - if (nodeAbove.node && (nodeAbove.node.type == "SheetOutsideText" || nodeAbove.node.type == "paragraph" ) ) { - Transforms.delete(editor, {at: path}) - } - - - Transforms.move(editor, { unit: 'block', distance: 1 }) - }); + const {en: normalEnRef, he: normalHeRef} = await sheetsUtils.getNormalRef(ref); + + let segments = await sheetsUtils.getSegmentObjs([ref]) + + const enText = sheetsUtils.segmentsToSourceText(segments, 'en'); + + const heText = sheetsUtils.segmentsToSourceText(segments, 'he'); + + let fragment = [{ + type: "SheetSource", + node: editor.children[0].nextNode, + ref: normalEnRef, + heRef: normalHeRef, + heText: parseSheetItemHTML(heText), + enText: parseSheetItemHTML(enText), + title: null, + children: [ + {text: ""}, + ] + }]; + + if (!(nodeBelow.node && (nodeBelow.node.type == "SheetOutsideText" || nodeBelow.node.type == "paragraph" ) )) { + fragment.push({type: 'spacer', children: [{text: ""}]}) + } + Transforms.setNodes(editor, { loading: false }, { at: path }); + addItemToSheet(editor, fragment); + checkAndFixDuplicateSheetNodeNumbers(editor) + if (nodeAbove.node && (nodeAbove.node.type == "SheetOutsideText" || nodeAbove.node.type == "paragraph" ) ) { + Transforms.delete(editor, {at: path}) + } + + + Transforms.move(editor, { unit: 'block', distance: 1 }) }; @@ -2488,7 +2541,7 @@ const SefariaEditor = (props) => { const editorContainer = useRef(); const [sheet, setSheet] = useState(props.data); const initValue = [{type: "sheet", children: [{text: ""}]}]; - const renderElement = useCallback(props => , []); + const renderElement = useCallback(props => , []); const [value, setValue] = useState(initValue); const [currentDocument, setCurrentDocument] = useState(initValue); const [unsavedChanges, setUnsavedChanges] = useState(false); @@ -2948,7 +3001,6 @@ const SefariaEditor = (props) => { [] ); - return (
{ diff --git a/static/js/ElasticSearchQuerier.jsx b/static/js/ElasticSearchQuerier.jsx new file mode 100644 index 0000000000..81c07c33e2 --- /dev/null +++ b/static/js/ElasticSearchQuerier.jsx @@ -0,0 +1,381 @@ +import Component from "react-class"; +import {SearchTotal} from "./sefaria/searchTotal"; +import Sefaria from "./sefaria/sefaria"; +import $ from "./sefaria/sefariaJquery"; +import ReactDOM from "react-dom"; +import SearchState from "./sefaria/searchState"; +import extend from "extend"; +import SearchTextResult from "./SearchTextResult"; +import SearchSheetResult from "./SearchSheetResult"; +import {LoadingMessage} from "./Misc"; +import PropTypes from "prop-types"; +import React from "react"; +import {SearchResultList} from "./SearchResultList"; +import SearchPage from "./SearchPage"; + +class TopicQuerier { + async addCollection(collection) { + const d = await Sefaria.getCollection(collection.key); + return { + analyticCat: "Collection", + title: d.name, + heTitle: d.name, + url: "/collections/" + collection.key, + topicCat: "Collections", + heTopicCat: Sefaria.hebrewTranslation("Collections"), + enDesc: d.description, + heDesc: d.description, + numSheets: d.sheets.length + } + } + async addRefTopic(topic) { + const book = await Sefaria.getIndexDetails(topic.key); + return { + enDesc: book.enDesc || book.enShortDesc, + heDesc: book.heDesc || book.heShortDesc, + title: book.title, + heTitle: book.heTitle, + topicCat: book.categories[0], + heTopicCat: Sefaria.toc.filter(cat => cat.category === book.categories[0])[0].heCategory, + url: "/" + book.title, + analyticCat: "Book" + } + } + addTOCCategoryTopic(topic) { + const topicKeyArr = topic.key.slice(); + const lastCat = topicKeyArr.pop(topicKeyArr - 1); //go up one level in order to get the bottom level's description + const relevantCats = topicKeyArr.length === 0 ? Sefaria.toc : Sefaria.tocItemsByCategories(topicKeyArr); + const relevantSubCat = relevantCats.filter(cat => "category" in cat && cat.category === lastCat)[0]; + return { + analyticCat: "Category", + url: "/texts/" + topic.key.join("/"), + topicCat: "Texts", + heTopicCat: Sefaria.hebrewTerm("Texts"), + enDesc: relevantSubCat.enDesc, + heDesc: relevantSubCat.heDesc, + title: relevantSubCat.category, + heTitle: relevantSubCat.heCategory + } + } + async addGeneralTopic(topic) { + const d = await Sefaria.getTopic(topic.key, {annotated: false}); + let searchTopic = { + analyticCat: "Topic", + title: d.primaryTitle["en"], + heTitle: d.primaryTitle["he"], + numSources: 0, + numSheets: 0, + url: "/topics/" + topic.key + } + const typeObj = Sefaria.topicTocCategory(topic.key); + if (!typeObj) { + searchTopic.topicCat = "Topics"; + searchTopic.heTopicCat = Sefaria.hebrewTranslation("Topics"); + } else { + searchTopic.topicCat = typeObj["en"]; + searchTopic.heTopicCat = typeObj["he"]; + } + if ("description" in d) { + searchTopic.enDesc = d.description["en"]; + searchTopic.heDesc = d.description["he"]; + } + if (d.tabs?.sources) { + searchTopic.numSources = d.tabs.sources.refs.length; + } + if (d.tabs?.sheets) { + searchTopic.numSheets = d.tabs.sheets.refs.length; + } + return searchTopic; + } +} +class ElasticSearchQuerier extends Component { + constructor(props) { + super(props); + this.querySize = {"text": 50, "sheet": 20}; + this.state = { + runningQueries: null, + isQueryRunning: false, + moreToLoad: true, + totals: new SearchTotal(), + pagesLoaded: 0, + hits: [], + error: false, + topics: [] + } + + // Load search results from cache so they are available for immediate render + + const args = this._getQueryArgs(props); + let cachedQuery = Sefaria.search.getCachedQuery(args); + while (cachedQuery) { + // Load all pages of results that are available in cache, so if page X was + // previously loaded it will be returned. + //console.log("Loaded cached query for") + //console.log(args); + this.state.hits = this.state.hits.concat(cachedQuery.hits.hits); + this.state.totals = cachedQuery.hits.total; + this.state.pagesLoaded += 1; + args.start = this.state.pagesLoaded * this.querySize[this.props.type]; + if (this.props.type === "text") { + // Since texts only have one filter type, aggregations are only requested once on first page + args.aggregationsToUpdate = []; + } + cachedQuery = Sefaria.search.getCachedQuery(args); + } + } + componentDidMount() { + this._executeAllQueries(); + } + componentWillUnmount() { + this._abortRunningQuery(); // todo: make this work w/ promises + } + componentWillReceiveProps(newProps) { + let state = { + hits: [], + pagesLoaded: 0, + moreToLoad: true + }; + if (this.props.query !== newProps.query) { + this.setState(state, () => this._executeAllQueries(newProps)); + } else if (this._shouldUpdateQuery(this.props, newProps, this.props.type)) { + this.setState(state, () => { + this._executeQuery(newProps, this.props.type); + }) + } + } + + async _executeTopicQuery() { + const topicQuerier = new TopicQuerier(); + const d = await Sefaria.getName(this.props.query) + let topics = d.completion_objects.filter(obj => obj.title.toUpperCase() === this.props.query.toUpperCase()); + const hasAuthor = topics.some(obj => obj.type === "AuthorTopic"); + if (hasAuthor) { + topics = topics.filter(obj => obj.type !== "TocCategory"); //TocCategory is unhelpful if we have author + } + let searchTopics = await Promise.all(topics.map(async t => { + if (t.type === 'ref') { + return await topicQuerier.addRefTopic(t); + } else if (t.type === 'TocCategory') { + return topicQuerier.addTOCCategoryTopic(t); + } else if (t.type === 'Collection') { + return await topicQuerier.addCollection(t); + } else { + return await topicQuerier.addGeneralTopic(t); + } + })); + this.setState({topics: searchTopics}); + } + updateRunningQuery(ajax) { + this.state.runningQueries = ajax; + this.state.isQueryRunning = !!ajax; + this.setState(this.state); + } + _abortRunningQuery() { + if(this.state.runningQueries) { + this.state.runningQueries.abort(); //todo: make work with promises + } + this.updateRunningQuery(null); + } + _shouldUpdateQuery(oldProps, newProps) { + const oldSearchState = this._getSearchState(oldProps); + const newSearchState = this._getSearchState(newProps); + return !oldSearchState.isEqual({ other: newSearchState, fields: ['appliedFilters', 'field', 'sortType'] }) || + ((oldSearchState.filtersValid !== newSearchState.filtersValid) && oldSearchState.appliedFilters.length > 0); // Execute a second query to apply filters after an initial query which got available filters + } + _getSearchState(props) { + props = props || this.props; + if (!props.query) { + return; + } + return props['searchState']; + } + _executeAllQueries(props) { + if (!this.props.searchInBook) { + this._executeTopicQuery(); + } + this._executeQuery(props, this.props.type); + } + _getAggsToUpdate(filtersValid, aggregation_field_array, aggregation_field_lang_suffix_array, appliedFilterAggTypes, type) { + // Returns a list of aggregations type which we should request from the server. + + // If there is only on possible filter (i.e. path for text) and filters are valid, no need to request again for any filter interactions + if (filtersValid && aggregation_field_array.length === 1) { return []; } + + return Sefaria.util + .zip(aggregation_field_array, aggregation_field_lang_suffix_array) + .map(([agg, suffix_map]) => `${agg}${suffix_map ? suffix_map[Sefaria.interfaceLang] : ''}`); // add suffix based on interfaceLang to filter, if present in suffix_map + } + _executeQuery(props) { + //This takes a props object, so as to be able to handle being called from componentWillReceiveProps with newProps + props = props || this.props; + if (!props.query) { + return; + } + this._abortRunningQuery(); + + let args = this._getQueryArgs(props); + + // If there are no available filters yet, don't apply filters. Split into two queries: + // 1) Get all potential filters and counts + // 2) Apply filters (Triggered from componentWillReceiveProps) + + const request_applied = args.applied_filters; + const searchState = this._getSearchState(props); + const { appliedFilters, appliedFilterAggTypes } = searchState; + const { aggregation_field_array, build_and_apply_filters } = SearchState.metadataByType[this.props.type]; + + args.success = data => { + this.updateRunningQuery(null); + if (this.state.pagesLoaded === 0) { // Skip if pages have already been loaded from cache, but let aggregation processing below occur + const currTotal = data.hits.total; + let state = { + hits: data.hits.hits, + totals: currTotal, + pagesLoaded: 1, + moreToLoad: currTotal.getValue() > this.querySize[this.props.type] + }; + this.setState(state); + const filter_label = (request_applied && request_applied.length > 0) ? (' - ' + request_applied.join('|')) : ''; + const query_label = props.query + filter_label; + Sefaria.track.event("Search", `${this.props.searchInBook? "SidebarSearch ": ""}Query: ${this.props.type}`, query_label, data.hits.total.getValue()); + } + + if (data.aggregations) { + let availableFilters = []; + let registry = {}; + let orphans = []; + for (let aggregation of args.aggregationsToUpdate) { + if (!!data.aggregations[aggregation]) { + const { buckets } = data.aggregations[aggregation]; + const { + availableFilters: tempAvailable, + registry: tempRegistry, + orphans: tempOrphans + } = Sefaria.search[build_and_apply_filters](buckets, appliedFilters, appliedFilterAggTypes, aggregation); + availableFilters.push(...tempAvailable); // array concat + registry = extend(registry, tempRegistry); + orphans.push(...tempOrphans); + } + } + this.props.registerAvailableFilters(this.props.type, availableFilters, registry, orphans, args.aggregationsToUpdate); + } + }; + args.error = this._handleError; + + const runningQuery = Sefaria.search.execute_query(args); + this.updateRunningQuery(runningQuery); + } + _getQueryArgs(props) { + props = props || this.props; + + const searchState = this._getSearchState(props); + const { field, fieldExact, sortType, filtersValid, appliedFilters, appliedFilterAggTypes } = searchState; + const request_applied = filtersValid && appliedFilters; + const { aggregation_field_array, aggregation_field_lang_suffix_array } = SearchState.metadataByType[this.props.type]; + const aggregationsToUpdate = this._getAggsToUpdate(filtersValid, aggregation_field_array, aggregation_field_lang_suffix_array, appliedFilterAggTypes, this.props.type); + + return { + query: props.query, + type: this.props.type, + applied_filters: request_applied, + appliedFilterAggTypes, + aggregationsToUpdate, + size: this.querySize[this.props.type], + field, + sort_type: sortType, + exact: fieldExact === field, + }; + } + _loadNextPage() { + console.log("load next page") + const args = this._getQueryArgs(this.props); + args.start = this.state.pagesLoaded * this.querySize[this.props.type]; + args.error = () => console.log("Failure in SearchResultList._loadNextPage"); + args.success = data => { + let nextHits = this.state.hits.concat(data.hits.hits); + + this.state.hits = nextHits; + this.state.pagesLoaded += 1; + if (this.state.pagesLoaded * this.querySize[this.props.type] >= this.state.totals.getValue() ) { + this.state.moreToLoad = false; + } + + this.setState(this.state); + this.updateRunningQuery(null); + }; + + const runningNextPageQuery = Sefaria.search.execute_query(args); + this.updateRunningQuery(runningNextPageQuery, false); + } + _handleError(jqXHR, textStatus, errorThrown) { + if (textStatus === "abort") { + // Abort is immediately followed by new query, above. Worried there would be a race if we call updateCurrentQuery(null) from here + //this.updateCurrentQuery(null); + return; + } + this.setState({error: true}); + this.updateRunningQuery(null); + } + normalizeHitsMetaData() { + if (this.props.type === 'sheet') { + let results = this.state.hits; + return results.map(result => { + let normalizedResult = result._source; + normalizedResult.snippet = result.highlight.content.join('...'); + return normalizedResult; + }) + } + else { + return this.state.hits; + } + } + render () { + return + } +} + +ElasticSearchQuerier.propTypes = { + query: PropTypes.string, + type: PropTypes.oneOf(["text", "sheet"]), + searchState: PropTypes.object, + onResultClick: PropTypes.func, + registerAvailableFilters: PropTypes.func, + settings: PropTypes.object, + openDisplaySettings: PropTypes.func, + toggleLanguage: PropTypes.func, + compare: PropTypes.bool, + close: PropTypes.func, + panelsOpen: PropTypes.number, + onQueryChange: PropTypes.func, + updateAppliedFilter: PropTypes.func, + updateAppliedOptionSort: PropTypes.func, + updateAppliedOptionField: PropTypes.func +}; + +export { ElasticSearchQuerier }; \ No newline at end of file diff --git a/static/js/Footer.jsx b/static/js/Footer.jsx index e95cbf6bf8..79a9a60350 100644 --- a/static/js/Footer.jsx +++ b/static/js/Footer.jsx @@ -45,6 +45,7 @@ class Footer extends Component { + diff --git a/static/js/Header.jsx b/static/js/Header.jsx index ce31a5e7fb..4387e28f2e 100644 --- a/static/js/Header.jsx +++ b/static/js/Header.jsx @@ -294,6 +294,12 @@ const MobileNavMenu = ({onRefClick, showSearch, openTopic, openURL, close, visib + + + + + + Get Help diff --git a/static/js/Misc.jsx b/static/js/Misc.jsx index 6b9d179af9..735b24afd3 100644 --- a/static/js/Misc.jsx +++ b/static/js/Misc.jsx @@ -23,6 +23,7 @@ import Cookies from "js-cookie"; import {EditTextInfo} from "./BookPage"; import ReactMarkdown from 'react-markdown'; import TrackG4 from "./sefaria/trackG4"; +import { ReaderApp } from './ReaderApp'; /** * Component meant to simply denote a language specific string to go inside an InterfaceText element @@ -1505,9 +1506,9 @@ const AiInfoTooltip = () => {
setShowMessage(true)} onMouseLeave={() => setShowMessage(false)}>
- Some of the text on this page has been AI generated and reviewed by our editors. Learn more. + Some of the text on this page has been AI generated and reviewed by our editors. Learn more. חלק מהטקסטים בדף זה נוצרו על ידי בינה מלאכותית ועברו הגהה על ידי צוות העורכים שלנו.  - לפרטים נוספים + לפרטים נוספים
@@ -3300,6 +3301,41 @@ const AppStoreButton = ({ platform, href, altText }) => { ); }; +const handleAnalyticsOnMarkdown = (e, gtag_fxn, rank, product, cta, label, link_type, analytics_event) => { + + + // get the lowest level parent element of an event target that is an HTML link tag. Or Null. + let target = e.target; + let linkTarget = null; + let parent = target; + let outmost = e.currentTarget; + let text = ""; + + while (parent) { + if(parent.nodeName === 'A'){ + linkTarget = parent; + text = linkTarget.text + break; + } + else if (parent.parentNode === outmost) { + return null; + } + parent = parent.parentNode; + } + + // Ignore clicks from non-a elements. + if (!linkTarget) { + return; + } + const href = linkTarget.getAttribute('href'); + if (!href) { + return; + } + else { + gtag_fxn(rank, product, text, label, link_type, analytics_event); + } +} + export { AppStoreButton, @@ -3367,5 +3403,6 @@ export { TitleVariants, OnInView, TopicPictureUploader, - ImageWithCaption + ImageWithCaption, + handleAnalyticsOnMarkdown }; diff --git a/static/js/NavSidebar.jsx b/static/js/NavSidebar.jsx index 1aaadeaee4..4e4f42fca4 100644 --- a/static/js/NavSidebar.jsx +++ b/static/js/NavSidebar.jsx @@ -39,7 +39,6 @@ const Modules = ({type, props}) => { "RelatedTopics": RelatedTopics, "TitledText": TitledText, "Visualizations": Visualizations, - "JoinTheConversation": JoinTheConversation, "JoinTheCommunity": JoinTheCommunity, "GetTheApp": GetTheApp, "StayConnected": StayConnected, @@ -609,27 +608,6 @@ const RelatedTopics = ({title}) => { ); }; - -const JoinTheConversation = ({wide}) => { - if (!Sefaria.multiPanel) { return null; } // Don't advertise create sheets on mobile (yet) - - return ( - -
- Join the Conversation - Combine sources from our library with your own comments, questions, images, and videos. -
- -
- ); -}; - - const JoinTheCommunity = ({wide}) => { return ( diff --git a/static/js/ReaderApp.jsx b/static/js/ReaderApp.jsx index 42471d2d55..acc10c7750 100644 --- a/static/js/ReaderApp.jsx +++ b/static/js/ReaderApp.jsx @@ -23,6 +23,7 @@ import { WordByWordPage, JobsPage, TeamMembersPage, + ProductsPage } from './StaticPages'; import UpdatesPanel from './UpdatesPanel'; import { @@ -54,6 +55,7 @@ class ReaderApp extends Component { searchType: props.initialSearchType, tab: props.initialTab, topicSort: props.initialTopicSort, + sheetsWithRef: props.sheetsWithRef, textSearchState: new SearchState({ type: 'text', appliedFilters: props.initialTextSearchFilters, @@ -143,6 +145,7 @@ class ReaderApp extends Component { navigationTopicCategory: state.navigationTopicCategory || "", sheetID: state.sheetID || null, sheetNodes: state.sheetNodes || null, + sheetsWithRef: state.sheetsWithRef || null, nodeRef: state.nodeRef || null, navigationTopic: state.navigationTopic || null, navigationTopicTitle: state.navigationTopicTitle || null, @@ -453,6 +456,13 @@ class ReaderApp extends Component { hist.url = "texts" + (cats ? "/" + cats : ""); hist.mode = "navigation"; break; + case "sheetsWithRef": + hist.title = Sefaria._("Sheets with ") + state.sheetsWithRef[shortLang] + Sefaria._(" on Sefaria"); + const encodedSheetsWithRef = state.sheetsWithRef.en ? encodeURIComponent(state.sheetsWithRef.en) : ""; + hist.url = "sheets/sheets-with-ref" + (state.sheetsWithRef.en ? (`/${encodedSheetsWithRef}` + + state.sheetSearchState.makeURL({ prefix: 's', isStart: false })) : ""); + hist.mode = "sheetsWithRef"; + break; case "text toc": var ref = state.refs.slice(-1)[0]; var bookTitle = ref ? Sefaria.parseRef(ref).index : "404"; @@ -1191,6 +1201,10 @@ toggleSignUpModal(modalContentKind = SignUpModalKind.Default) { }; this.setPanelState(n, updates); } + updateSearchState(n, searchState, type) { + const searchStateName = this._getSearchStateName(type); + this.setPanelState(n,{[searchStateName]: searchState}); + } updateAvailableFilters(n, type, availableFilters, filterRegistry, orphanFilters, aggregationsToUpdate) { const state = this.state.panels[n]; const searchState = this._getSearchState(state, type); @@ -2133,6 +2147,7 @@ toggleSignUpModal(modalContentKind = SignUpModalKind.Default) { var unsetTextHighlight = this.unsetTextHighlight.bind(null, i); var updateQuery = this.updateQuery.bind(null, i); var updateAvailableFilters = this.updateAvailableFilters.bind(null, i); + let updateSearchState = this.updateSearchState.bind(null, i); var updateSearchFilter = this.updateSearchFilter.bind(null, i); var updateSearchOptionField = this.updateSearchOptionField.bind(null, i); var updateSearchOptionSort = this.updateSearchOptionSort.bind(null, i); @@ -2163,6 +2178,7 @@ toggleSignUpModal(modalContentKind = SignUpModalKind.Default) { panels.push(
); - + key={this.state.navigationCategories ? this.state.navigationCategories.join("-") : this.state.navigationTopicCategory ? this.state.navigationTopicCategory : "navHome"} + compare={this.state.compare} + multiPanel={this.props.multiPanel} + categories={this.state.navigationCategories || []} + settings={this.state.settings} + setCategories={this.setNavigationCategories} + openTextTOC={openTextTOC} + setOption={this.setOption} + toggleLanguage={this.toggleLanguage} + onCompareBack={this.props.closePanel} + openSearch={this.openSearch} + openDisplaySettings={this.openDisplaySettings} + initialWidth={this.state.width} + toggleSignUpModal={this.props.toggleSignUpModal}/>); + } else if (this.state.menuOpen === "sheetsWithRef") { + menu = (); } else if (this.state.menuOpen === "sheet meta") { menu = (); } else if (this.state.menuOpen === "search" && this.state.searchQuery) { - menu = (); - + menu = (); } else if (this.state.menuOpen === "topics") { if (this.state.navigationTopicCategory) { menu = ( diff --git a/static/js/SearchFilters.jsx b/static/js/SearchFilters.jsx index 713184dfbb..2b410663fa 100644 --- a/static/js/SearchFilters.jsx +++ b/static/js/SearchFilters.jsx @@ -195,26 +195,22 @@ const SearchFilterGroup = ({name, filters, updateSelected, expandable, paged, se return false; } } - - const wordSelected = (item) => { - if (item.selected) { - return -1; - } else { - return 1; - } + + const sortFiltersBySelected = (filter1, filter2) => { + return filter2.selected - filter1.selected; } const updateFilters = text => { - if (text && text != "") { + if (text && text !== "") { if (!expandable) { - setFilters(filters.filter(x => hasWordStartingWithOrSelected(x, text)).sort(x => wordSelected(x))); + setFilters(filters.filter(x => hasWordStartingWithOrSelected(x, text)).sort(sortFiltersBySelected)); } else { // don't sort setFilters(filters.filter(x => hasWordStartingWithOrSelected(x, text))); } setShowClearInputButton(true); } else { if (!expandable) { - setFilters(filters.sort(x => wordSelected(x))); + setFilters(filters.sort(sortFiltersBySelected)); } else { setFilters(filters); } diff --git a/static/js/SearchPage.jsx b/static/js/SearchPage.jsx index 42c6ce7088..a87ce04aa0 100644 --- a/static/js/SearchPage.jsx +++ b/static/js/SearchPage.jsx @@ -6,9 +6,10 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import Footer from './Footer'; import ComparePanelHeader from './ComparePanelHeader'; -import SearchResultList from './SearchResultList'; import SearchFilters from './SearchFilters'; import Component from 'react-class'; +import {SearchSortBox, SearchFilterButton} from './SearchResultList'; +import {SearchResultList} from "./SearchResultList"; import { CategoryColorLine, InterfaceText, @@ -24,73 +25,92 @@ class SearchPage extends Component { }; } render () { - const classes = classNames({readerNavMenu: 1, compare: this.props.compare}); - const isQueryHebrew = Sefaria.hebrew.isHebrew(this.props.query); + const classes = classNames({readerNavMenu: 1, compare: this.props.compare}); + const isQueryHebrew = Sefaria.hebrew.isHebrew(this.props.query); + const searchResultList = ; + const sortComponent =
+ {Sefaria.multiPanel && !this.props.compare ? + + : + this.setState({mobileFiltersOpen: true})} + nFilters={this.props.searchState.appliedFilters.length}/>} +
+ if (this.props.searchInBook) { + return searchResultList; + } return ( -
- {this.props.compare ? - : null} +
+ {this.props.compare ? + : null} -
-
-
- -
-

- Results for  - - { this.props.query } - -

- {this.state.totalResults?.getValue() > 0 ? -
- {this.state.totalResults.asString()}  - Results +
+
+
+ +
+

+ {this.props.searchTopMsg}  + + {this.props.query} + +

+ {this.props.totalResults?.getValue() > 0 ? +
+ {this.props.totalResults.asString()}  + Results +
+ : null}
- : null } + {sortComponent} + {searchResultList}
- this.setState({totalResults: n})} - openMobileFilters={() => this.setState({mobileFiltersOpen: true})} - /> -
- - {(Sefaria.multiPanel && !this.props.compare) || this.state.mobileFiltersOpen ? -
- {this.state.totalResults?.getValue() > 0 ? - this.setState({mobileFiltersOpen: false})} - compare={this.props.compare} - type={this.props.type} /> - : null } + {(Sefaria.multiPanel && !this.props.compare) || this.state.mobileFiltersOpen ? +
+ {this.props.totalResults?.getValue() > 0 ? + this.setState({mobileFiltersOpen: false})} + compare={this.props.compare} + type={this.props.type}/> + : null} +
+ : null}
- : null } + {this.props.panelsOpen === 1 ?
: null}
- { this.props.panelsOpen === 1 ?
: null }
-
); } } + SearchPage.propTypes = { - interfaceLang: PropTypes.oneOf(["english", "hebrew"]), query: PropTypes.string, type: PropTypes.oneOf(["text", "sheet"]), searchState: PropTypes.object, @@ -103,6 +123,11 @@ SearchPage.propTypes = { updateAppliedOptionField: PropTypes.func, updateAppliedOptionSort: PropTypes.func, registerAvailableFilters: PropTypes.func, + loadNextPage: PropTypes.func, + moreToLoad: PropTypes.bool, + topics: PropTypes.array, + totalResults: PropTypes.object, + sortTypeArray: PropTypes.array, }; diff --git a/static/js/SearchResultList.jsx b/static/js/SearchResultList.jsx index 8bac81bfd6..d479a27a35 100644 --- a/static/js/SearchResultList.jsx +++ b/static/js/SearchResultList.jsx @@ -6,12 +6,9 @@ import extend from 'extend'; import classNames from 'classnames'; import $ from './sefaria/sefariaJquery'; import Sefaria from './sefaria/sefaria'; -import { SearchTotal } from "./sefaria/searchTotal"; import SearchTextResult from './SearchTextResult'; import SearchSheetResult from './SearchSheetResult'; -import SearchFilters from './SearchFilters'; import SearchState from './sefaria/searchState'; -import Strings from "./sefaria/strings.js" import { DropdownModal, DropdownButton, @@ -79,326 +76,22 @@ const SearchTopic = (props) => { class SearchResultList extends Component { constructor(props) { super(props); - this.querySize = {"text": 50, "sheet": 20}; - this.state = { - runningQueries: null, - isQueryRunning: false, - moreToLoad: true, - totals: new SearchTotal(), - pagesLoaded: 0, - hits: [], - error: false, - topics: [] - } - - // Load search results from cache so they are available for immediate render - - const args = this._getQueryArgs(props); - let cachedQuery = Sefaria.search.getCachedQuery(args); - while (cachedQuery) { - // Load all pages of results that are available in cache, so if page X was - // previously loaded it will be returned. - //console.log("Loaded cached query for") - //console.log(args); - this.state.hits = this.state.hits.concat(cachedQuery.hits.hits); - this.state.totals = cachedQuery.hits.total; - this.state.pagesLoaded += 1; - args.start = this.state.pagesLoaded * this.querySize[this.props.type]; - if (this.props.type === "text") { - // Since texts only have one filter type, aggregations are only requested once on first page - args.aggregationsToUpdate = []; - } - cachedQuery = Sefaria.search.getCachedQuery(args); - } - this.updateTotalResults(); } componentDidMount() { - this._executeAllQueries(); $(ReactDOM.findDOMNode(this)).closest(".content").on("scroll.infiteScroll", this.handleScroll); } componentWillUnmount() { - this._abortRunningQuery(); // todo: make this work w/ promises $(ReactDOM.findDOMNode(this)).closest(".content").off("scroll.infiniteScroll", this.handleScroll); } - componentWillReceiveProps(newProps) { - if(this.props.query !== newProps.query) { - this.setState({ - totals: new SearchTotal(), - hits: [], - moreToLoad: true, - }); - this._executeAllQueries(newProps); - } else if (this._shouldUpdateQuery(this.props, newProps, this.props.type)) { - let state = { - hits: [], - pagesLoaded: 0, - moreToLoad: true - }; - this.setState(state, () => { - this._executeQuery(newProps, this.props.type); - }) - } - } - async addRefTopic(topic) { - const book = await Sefaria.getIndexDetails(topic.key); - return { - enDesc: book.enDesc || book.enShortDesc, - heDesc: book.heDesc || book.heShortDesc, - title: book.title, - heTitle: book.heTitle, - topicCat: book.categories[0], - heTopicCat: Sefaria.toc.filter(cat => cat.category === book.categories[0])[0].heCategory, - url: "/" + book.title, - analyticCat: "Book" - } - } - addTOCCategoryTopic(topic) { - const topicKeyArr = topic.key.slice(); - const lastCat = topicKeyArr.pop(topicKeyArr - 1); //go up one level in order to get the bottom level's description - const relevantCats = topicKeyArr.length === 0 ? Sefaria.toc : Sefaria.tocItemsByCategories(topicKeyArr); - const relevantSubCat = relevantCats.filter(cat => "category" in cat && cat.category === lastCat)[0]; - return { - analyticCat: "Category", - url: "/texts/" + topic.key.join("/"), - topicCat: "Texts", - heTopicCat: Sefaria.hebrewTerm("Texts"), - enDesc: relevantSubCat.enDesc, - heDesc: relevantSubCat.heDesc, - title: relevantSubCat.category, - heTitle: relevantSubCat.heCategory - } - } - async addGeneralTopic(topic) { - const d = await Sefaria.getTopic(topic.key, {annotated: false}); - let searchTopic = { - analyticCat: "Topic", - title: d.primaryTitle["en"], - heTitle: d.primaryTitle["he"], - numSources: 0, - numSheets: 0, - url: "/topics/" + topic.key - } - const typeObj = Sefaria.topicTocCategory(topic.key); - if (!typeObj) { - searchTopic.topicCat = "Topics"; - searchTopic.heTopicCat = Sefaria.hebrewTranslation("Topics"); - } else { - searchTopic.topicCat = typeObj["en"]; - searchTopic.heTopicCat = typeObj["he"]; - } - if ("description" in d) { - searchTopic.enDesc = d.description["en"]; - searchTopic.heDesc = d.description["he"]; - } - if (d.tabs?.sources) { - searchTopic.numSources = d.tabs.sources.refs.length; - } - if (d.tabs?.sheets) { - searchTopic.numSheets = d.tabs.sheets.refs.length; - } - return searchTopic; - } - async addCollection(collection) { - const d = await Sefaria.getCollection(collection.key); - return { - analyticCat: "Collection", - title: d.name, - heTitle: d.name, - url: "/collections/" + collection.key, - topicCat: "Collections", - heTopicCat: Sefaria.hebrewTranslation("Collections"), - enDesc: d.description, - heDesc: d.description, - numSheets: d.sheets.length - } - } - async _executeTopicQuery() { - const d = await Sefaria.getName(this.props.query) - let topics = d.completion_objects.filter(obj => obj.title.toUpperCase() === this.props.query.toUpperCase()); - const hasAuthor = topics.some(obj => obj.type === "AuthorTopic"); - if (hasAuthor) { - topics = topics.filter(obj => obj.type !== "TocCategory"); //TocCategory is unhelpful if we have author - } - let searchTopics = await Promise.all(topics.map(async t => { - if (t.type === 'ref') { - return await this.addRefTopic(t); - } else if (t.type === 'TocCategory') { - return this.addTOCCategoryTopic(t); - } else if (t.type === 'Collection') { - return await this.addCollection(t); - } else { - return await this.addGeneralTopic(t); - } - })); - this.setState({topics: searchTopics}); - } - updateRunningQuery(ajax) { - this.state.runningQueries = ajax; - this.state.isQueryRunning = !!ajax; - this.setState(this.state); - } - totalResults() { - return this.state.totals; - } - updateTotalResults() { - this.props.updateTotalResults(this.totalResults()); - } - _abortRunningQuery() { - if(this.state.runningQueries) { - this.state.runningQueries.abort(); //todo: make work with promises - } - this.updateRunningQuery(null); - } handleScroll() { - if (!this.state.moreToLoad) { return; } - if (this.state.runningQueries) { return; } + if (!this.props.moreToLoad) { return; } + if (this.props.isQueryRunning) { return; } var $scrollable = $(ReactDOM.findDOMNode(this)).closest(".content"); var margin = 300; if($scrollable.scrollTop() + $scrollable.innerHeight() + margin >= $scrollable[0].scrollHeight) { - this._loadNextPage(); - } - } - _shouldUpdateQuery(oldProps, newProps) { - const oldSearchState = this._getSearchState(oldProps); - const newSearchState = this._getSearchState(newProps); - return !oldSearchState.isEqual({ other: newSearchState, fields: ['appliedFilters', 'field', 'sortType'] }) || - ((oldSearchState.filtersValid !== newSearchState.filtersValid) && oldSearchState.appliedFilters.length > 0); // Execute a second query to apply filters after an initial query which got available filters - } - _getSearchState(props) { - props = props || this.props; - if (!props.query) { - return; + this.props.loadNextPage(); } - return props['searchState']; - } - _executeAllQueries(props) { - this._executeTopicQuery(); - this._executeQuery(props, this.props.type); - } - _getAggsToUpdate(filtersValid, aggregation_field_array, aggregation_field_lang_suffix_array, appliedFilterAggTypes, type) { - // Returns a list of aggregations type which we should request from the server. - - // If there is only on possible filter (i.e. path for text) and filters are valid, no need to request again for any filter interactions - if (filtersValid && aggregation_field_array.length === 1) { return []; } - - return Sefaria.util - .zip(aggregation_field_array, aggregation_field_lang_suffix_array) - .map(([agg, suffix_map]) => `${agg}${suffix_map ? suffix_map[Sefaria.interfaceLang] : ''}`); // add suffix based on interfaceLang to filter, if present in suffix_map - } - _executeQuery(props) { - //This takes a props object, so as to be able to handle being called from componentWillReceiveProps with newProps - props = props || this.props; - if (!props.query) { - return; - } - this._abortRunningQuery(); - - let args = this._getQueryArgs(props); - - // If there are no available filters yet, don't apply filters. Split into two queries: - // 1) Get all potential filters and counts - // 2) Apply filters (Triggered from componentWillReceiveProps) - - const request_applied = args.applied_filters; - const searchState = this._getSearchState(props); - const { appliedFilters, appliedFilterAggTypes } = searchState; - const { aggregation_field_array, build_and_apply_filters } = SearchState.metadataByType[this.props.type]; - - args.success = data => { - this.updateRunningQuery(null); - if (this.state.pagesLoaded === 0) { // Skip if pages have already been loaded from cache, but let aggregation processing below occur - const currTotal = data.hits.total; - let state = { - hits: data.hits.hits, - totals: currTotal, - pagesLoaded: 1, - moreToLoad: currTotal.getValue() > this.querySize[this.props.type] - }; - this.setState(state, () => { - this.updateTotalResults(); - this.handleScroll(); - }); - const filter_label = (request_applied && request_applied.length > 0) ? (' - ' + request_applied.join('|')) : ''; - const query_label = props.query + filter_label; - Sefaria.track.event("Search", `${this.props.searchInBook? "SidebarSearch ": ""}Query: ${this.props.type}`, query_label, data.hits.total.getValue()); - } - - if (data.aggregations) { - let availableFilters = []; - let registry = {}; - let orphans = []; - for (let aggregation of args.aggregationsToUpdate) { - if (!!data.aggregations[aggregation]) { - const { buckets } = data.aggregations[aggregation]; - const { - availableFilters: tempAvailable, - registry: tempRegistry, - orphans: tempOrphans - } = Sefaria.search[build_and_apply_filters](buckets, appliedFilters, appliedFilterAggTypes, aggregation); - availableFilters.push(...tempAvailable); // array concat - registry = extend(registry, tempRegistry); - orphans.push(...tempOrphans); - } - } - this.props.registerAvailableFilters(this.props.type, availableFilters, registry, orphans, args.aggregationsToUpdate); - } - }; - args.error = this._handleError; - - const runningQuery = Sefaria.search.execute_query(args); - this.updateRunningQuery(runningQuery); - } - _getQueryArgs(props) { - props = props || this.props; - - const searchState = this._getSearchState(props); - const { field, fieldExact, sortType, filtersValid, appliedFilters, appliedFilterAggTypes } = searchState; - const request_applied = filtersValid && appliedFilters; - const { aggregation_field_array, aggregation_field_lang_suffix_array } = SearchState.metadataByType[this.props.type]; - const aggregationsToUpdate = this._getAggsToUpdate(filtersValid, aggregation_field_array, aggregation_field_lang_suffix_array, appliedFilterAggTypes, this.props.type); - - return { - query: props.query, - type: this.props.type, - applied_filters: request_applied, - appliedFilterAggTypes, - aggregationsToUpdate, - size: this.querySize[this.props.type], - field, - sort_type: sortType, - exact: fieldExact === field, - }; - } - _loadNextPage() { - console.log("load next page") - const args = this._getQueryArgs(this.props); - args.start = this.state.pagesLoaded * this.querySize[this.props.type]; - args.error = () => console.log("Failure in SearchResultList._loadNextPage"); - args.success = data => { - let nextHits = this.state.hits.concat(data.hits.hits); - - this.state.hits = nextHits; - this.state.pagesLoaded += 1; - if (this.state.pagesLoaded * this.querySize[this.props.type] >= this.state.totals.getValue() ) { - this.state.moreToLoad = false; - } - - this.setState(this.state); - this.updateRunningQuery(null); - }; - - const runningNextPageQuery = Sefaria.search.execute_query(args); - this.updateRunningQuery(runningNextPageQuery, false); - } - _handleError(jqXHR, textStatus, errorThrown) { - if (textStatus == "abort") { - // Abort is immediately followed by new query, above. Worried there would be a race if we call updateCurrentQuery(null) from here - //this.updateCurrentQuery(null); - return; - } - this.setState({error: true}); - this.updateRunningQuery(null); } render () { if (!(this.props.query)) { // Push this up? Thought is to choose on the SearchPage level whether to show a ResultList or an EmptySearchMessage. @@ -406,11 +99,10 @@ class SearchResultList extends Component { } const { type } = this.props; - const searchState = this._getSearchState(); let results = []; if (type === "text") { - results = Sefaria.search.mergeTextResultsVersions(this.state.hits); + results = Sefaria.search.mergeTextResultsVersions(this.props.hits); results = results.filter(result => !!result._source.version).map(result => ); - if (this.state.topics.length > 0) { - let topics = this.state.topics.map(t => { + if (this.props.topics.length > 0) { + let topics = this.props.topics.map(t => { Sefaria.track.event("Search", "topic in search display", t.analyticCat+"|"+t.title); return }); @@ -435,10 +127,10 @@ class SearchResultList extends Component { } else if (type === "sheet") { - results = this.state.hits.map(result => + results = this.props.hits.map((result, i) => @@ -447,56 +139,51 @@ class SearchResultList extends Component { const loadingMessage = (); const noResultsMessage = (); - - const queryFullyLoaded = !this.state.moreToLoad && !this.state.isQueryRunning; + const queryFullyLoaded = !this.props.moreToLoad && !this.props.isQueryRunning; const haveResults = !!results.length; results = haveResults ? results : noResultsMessage; return (
-
- {Sefaria.multiPanel && !this.props.compare ? - - : - } -
-
- { queryFullyLoaded || haveResults ? results : null } - { this.state.isQueryRunning ? loadingMessage : null } -
+
+ {queryFullyLoaded || haveResults ? results : null} + {this.props.isQueryRunning ? loadingMessage : null} +
); } } + SearchResultList.propTypes = { - query: PropTypes.string, - type: PropTypes.oneOf(["text", "sheet"]), - searchState: PropTypes.object, - onResultClick: PropTypes.func, - updateAppliedOptionSort: PropTypes.func, - registerAvailableFilters: PropTypes.func, + query: PropTypes.string, + type: PropTypes.oneOf(["text", "sheet"]), + searchState: PropTypes.object, + onResultClick: PropTypes.func, + updateAppliedOptionSort: PropTypes.func, + registerAvailableFilters: PropTypes.func, + loadNextPage: PropTypes.func, + queryFullyLoaded: PropTypes.bool, + isQueryRunning: PropTypes.bool, + topics: PropTypes.array }; -const SearchSortBox = ({type, updateAppliedOptionSort, sortType}) => { - const [isOpen, setIsOpen] = useState(false); +const SearchSortBox = ({type, updateAppliedOptionSort, sortType, sortTypeArray}) => { + const [isOpen, setIsOpen] = useState(false); - const handleClick = (newSortType) => { - if (sortType === newSortType) { - return; - } - updateAppliedOptionSort(type, newSortType); - setIsOpen(false); - } - const filterTextClasses = classNames({ searchFilterToggle: 1, active: isOpen }); - return ( - {setIsOpen(false)}} isOpen={isOpen}> - { + if (sortType === newSortType) { + return; + } + updateAppliedOptionSort(type, newSortType); + setIsOpen(false); + } + const filterTextClasses = classNames({searchFilterToggle: 1, active: isOpen}); + return ( + { + setIsOpen(false) + }} isOpen={isOpen}> + {setIsOpen(!isOpen)}} enText={"Sort"} heText={"מיון"} @@ -504,7 +191,7 @@ const SearchSortBox = ({type, updateAppliedOptionSort, sortType}) => { /> @@ -526,4 +213,4 @@ const SearchFilterButton = ({openMobileFilters, nFilters}) => ( ); -export default SearchResultList; +export { SearchResultList, SearchFilterButton, SearchSortBox }; diff --git a/static/js/SearchSheetResult.jsx b/static/js/SearchSheetResult.jsx index 3de7ed4f45..f0dec7ec4b 100644 --- a/static/js/SearchSheetResult.jsx +++ b/static/js/SearchSheetResult.jsx @@ -78,8 +78,7 @@ class SearchSheetResult extends Component { SearchSheetResult.propTypes = { query: PropTypes.string, - metadata: PropTypes.object, - snippet: PropTypes.string, + hit: PropTypes.object, onResultClick: PropTypes.func }; diff --git a/static/js/SidebarSearch.jsx b/static/js/SidebarSearch.jsx index 00e0509f50..24ef47904d 100644 --- a/static/js/SidebarSearch.jsx +++ b/static/js/SidebarSearch.jsx @@ -1,11 +1,9 @@ import { useState, useEffect } from "react"; -import {InterfaceText, EnglishText, HebrewText} from "./Misc"; import Sefaria from "./sefaria/sefaria"; import SearchState from './sefaria/searchState'; -import SearchResultList from './SearchResultList'; import DictionarySearch from './DictionarySearch'; import classNames from 'classnames'; - +import {ElasticSearchQuerier} from "./ElasticSearchQuerier"; import { SearchButton, } from './Misc'; @@ -111,22 +109,18 @@ const SidebarSearch = ({ title, updateAppliedOptionSort, navigatePanel, sidebarS
- {query ? - console.log(n)} - registerAvailableFilters={n => console.log(n)} - updateAppliedOptionSort={updateAppliedOptionSort} - onResultClick={onSidebarSearchClick} - /> : - - null - - } + {query && + console.log(n)} + registerAvailableFilters={n => console.log(n)} + updateAppliedOptionSort={updateAppliedOptionSort} + onResultClick={onSidebarSearchClick} + />}
diff --git a/static/js/StaticPages.jsx b/static/js/StaticPages.jsx index bd59a652ba..bb8721dbd8 100644 --- a/static/js/StaticPages.jsx +++ b/static/js/StaticPages.jsx @@ -3,7 +3,10 @@ import { SimpleInterfaceBlock, TwoOrThreeBox, ResponsiveNBox, - NBox, InterfaceText, + NBox, + InterfaceText, + HebrewText, + EnglishText, LoadingMessage, LoadingRing, } from './Misc'; @@ -11,6 +14,9 @@ import {NewsletterSignUpForm} from "./NewsletterSignUpForm"; import palette from './sefaria/palette'; import classNames from 'classnames'; import Cookies from 'js-cookie'; +import ReactMarkdown from 'react-markdown'; +import Sefaria from './sefaria/sefaria'; +import { OnInView, handleAnalyticsOnMarkdown } from './Misc'; /* Templates: @@ -3137,6 +3143,328 @@ const JobsPage = memo(() => { ); }); + +/* +* Products Page +*/ + +// The static content on the page inviting users to browse our "powered-by" products +const DevBox = () => { + return ( +
+

+ +

+

+ + + נסו את המוצרים שמפתחי תוכנה וידידי ספריא מרחבי העולם בנו עבורכם! גלו את הפרויקטים + + + Check out the products our software developer friends from around the world have been building for you! Explore + + +

+
+ ); + }; + +/** + * The following are the building block components of an individual product. + */ + +// The title and gray background label for each product +const ProductTitle = ({product}) => { + return ( +
+ + + + {product.type.en ? ( + + ) : ''} +
+ ); +}; + +// Generalized function for catching products page analytics - to be revisited +const productsAnalytics = (rank, product, cta, label, link_type, event) => { + gtag("event", `products_${event}`, { + project: 'Products', + panel_type: "strapi-static", + panel_number: 1, + panel_name: "Products", + position: rank, + link_text: cta, + experiment: label === 'Experiment' ? 1 : 0, + feature_name: product, + link_classes: link_type, + engagement_type: "navigation", + engagement_value: 0 + }); +} + + +// The call-to-action (link) in the heading of each product +// For desc link, change cta text to desc and "cta" to desc +// TODO - uncomment once analytics is confirmed +const ProductCTA = ({product, cta}) => { + return ( + + // productsAnalytics(product?.rank, product?.titles.en, cta.text.en, product?.type.en, "viewed")}> + productsAnalytics(product.rank, product.titles.en, cta.text.en, product.type.en, "cta", "clicked")}> + {cta.icon.url && Click icon} + + + + + + + // + + + ); +}; + +// The main body of each product entry, containing an image and description +const ProductDesc = ({product}) => { + return ( +
+
+ {`Image +
+
handleAnalyticsOnMarkdown(e, productsAnalytics, product.rank, product.titles.en, null, null, "product_desc", "clicked")}> + +
+
+ ); +}; + +// The main product component, comprised of the building block sub-components +const Product = ({product}) => { + return ( +
+
+ +
+ {product.ctaLabels?.map(cta => ( + + ))} +
+
+ +
+ ); +}; + + + + +const ProductsPage = memo(() => { + const [products, setProducts] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + loadProducts(); + }, []); + + // GraphQL query to Strapi + const fetchProductsJSON = async () => { + + // If the viewer is an admin, edit the query to retrieve the drafts as well + var includeDrafts = ''; + if (Sefaria.is_moderator){ + includeDrafts = 'publicationState:PREVIEW,'; + } + + const query = `query { + products ( + pagination: { limit: -1 }, + ${includeDrafts} + sort: "rank:asc" + ) + { + data { + id + attributes { + title + rank + url + type + description + rectanglion { + data { + attributes { + url + alternativeText + } + } + } + createdAt + updatedAt + locale + call_to_actions { + data { + id + attributes { + text + url + icon { + data { + id + attributes { + url + alternativeText + } + } + } + locale + localizations { + data { + id + attributes { + text + } + } + } + } + } + } + localizations { + data { + attributes { + locale + title + type + description + } + } + } + } + } + } + }`; + + try { + const response = await fetch(STRAPI_INSTANCE + "/graphql", { + method: "POST", + mode: "cors", + cache: "no-cache", + credentials: "omit", + headers: { + "Content-Type": "application/json", + }, + redirect: "follow", + referrerPolicy: "no-referrer", + body: JSON.stringify({ query }), + }); + if (!response.ok) { + throw new Error(`HTTP Error: ${response.statusText}`); + } + const data = await response.json(); + return data; + } catch (error) { + throw error; + } + }; + + // Loading Products data, and setting the state ordering the products by their `rank` + const loadProducts = async () => { + if (typeof STRAPI_INSTANCE !== "undefined" && STRAPI_INSTANCE) { + try { + const productsData = await fetchProductsJSON(); + + const productsFromStrapi = productsData.data?.products?.data?.map((productsData) => { + + const heLocalization = productsData.attributes?.localizations?.data[0]?.attributes; + const ctaLabels = productsData.attributes?.call_to_actions?.data; + + const ctaLabelsLocalized = ctaLabels.map((cta) => { + return { + text: { + en: cta.attributes?.text, + he: cta.attributes?.localizations?.data[0]?.attributes.text + }, + url: cta.attributes?.url, + icon: { + url: cta.attributes?.icon?.data?.attributes?.url, + }, + id: cta.id + }; + }); + + return { + id: productsData.id, + titles: { + en: productsData.attributes?.title, + he: heLocalization?.title + }, + rank: productsData.attributes?.rank, + type: { + en: productsData.attributes?.type, + he: productsData.attributes?.localizations?.data[0]?.attributes?.type, + }, + url: productsData.attributes?.url, + desc: + { + en: productsData.attributes?.description, + he: heLocalization?.description + }, + rectanglion: { + url: productsData.attributes?.rectanglion?.data?.attributes?.url, + }, + ctaLabels: ctaLabelsLocalized, + + }; + }, {}); + setProducts(productsFromStrapi); + } catch (error) { + console.error("Fetch error:", error); + setError("Error: Sefaria's CMS cannot be reached"); + } + } else { + setError("Error: Sefaria's CMS cannot be reached"); + } + }; + + + // In order to inject the static 'DevBox' in a fixed position on the page, we + // create an array of components, and then slice the list into two sub-lists at the + // desired insertion position for the 'DevBox'. When rendering, we render the + // first sub-list, the , and finally the second sub-list. + const ProductList = []; + if (products) { + for (const product of products) { + ProductList.push() + } + } + + const devBoxPosition = 2; + const initialProducts = ProductList.slice(0, devBoxPosition); + const remainingProducts = ProductList.slice(devBoxPosition); + + return ( + <> +

+ Sefaria's Products + מוצרים של בספריא +

+
+ {products && products.length > 0 ? ( + <> + {initialProducts} + {/* */} + {remainingProducts} + + ) : null} +
+ + ); +}); + + export { RemoteLearningPage, SheetsLandingPage, @@ -3150,5 +3478,6 @@ export { DonatePage, WordByWordPage, JobsPage, - TeamMembersPage + TeamMembersPage, + ProductsPage, }; diff --git a/static/js/TextRange.jsx b/static/js/TextRange.jsx index 38441ac3b3..777f34ada4 100644 --- a/static/js/TextRange.jsx +++ b/static/js/TextRange.jsx @@ -506,8 +506,8 @@ class TextSegment extends Component { handleRefLinkClick(refLink, event) { event.preventDefault(); let newRef = Sefaria.humanRef(refLink.attr("data-ref")); - const newBook = Sefaria.parseRef(newRef)?.book; - const currBook = Sefaria.parseRef(this.props.sref)?.book; + const newBook = Sefaria.parseRef(newRef)?.index; + const currBook = Sefaria.parseRef(this.props.sref)?.index; const isScrollLink = refLink.attr('data-scroll-link'); // two options: in most cases, we open a new panel, but if isScrollLink is 'true', we should navigate in the same panel to the new location diff --git a/static/js/TopicPageAll.jsx b/static/js/TopicPageAll.jsx index d732d7c0cd..c7addcd444 100644 --- a/static/js/TopicPageAll.jsx +++ b/static/js/TopicPageAll.jsx @@ -45,7 +45,6 @@ class TopicPageAll extends Component { const sidebarModules = [ {type: "Promo"}, {type: "TrendingTopics"}, - {type: "JoinTheConversation"}, {type: "GetTheApp"}, {type: "SupportSefaria"}, ]; diff --git a/static/js/TopicsPage.jsx b/static/js/TopicsPage.jsx index b79ab2c52c..6d61844374 100644 --- a/static/js/TopicsPage.jsx +++ b/static/js/TopicsPage.jsx @@ -50,7 +50,6 @@ const TopicsPage = ({setNavTopic, multiPanel, initialWidth}) => { const sidebarModules = [ multiPanel ? {type: "AboutTopics"} : {type: null}, {type: "TrendingTopics"}, - {type: "JoinTheConversation"}, {type: "GetTheApp"}, {type: "SupportSefaria"}, ]; diff --git a/static/js/UserProfile.jsx b/static/js/UserProfile.jsx index 37969a2c49..424acacd51 100644 --- a/static/js/UserProfile.jsx +++ b/static/js/UserProfile.jsx @@ -342,7 +342,7 @@ class UserProfile extends Component { return (
- {this.props.profile.show_editor_toggle ? : null} + {(this.props.profile.id === Sefaria._uid && this.props.profile.show_editor_toggle) ? : null}
{ !this.props.profile.id ? :
diff --git a/static/js/sefaria/sefaria.js b/static/js/sefaria/sefaria.js index c9b99d7673..4bbcf901a5 100644 --- a/static/js/sefaria/sefaria.js +++ b/static/js/sefaria/sefaria.js @@ -10,6 +10,8 @@ import Hebrew from './hebrew'; import Util from './util'; import $ from './sefariaJquery'; import Cookies from 'js-cookie'; +import SearchState from "./searchState"; +import FilterNode from "./FilterNode"; let Sefaria = Sefaria || { @@ -274,6 +276,12 @@ Sefaria = extend(Sefaria, { let index = Sefaria.index(pRef.index); return index && index.categories ? index.categories : []; }, + refIndexTitle: function(ref) { + let pRef = Sefaria.parseRef(ref); + if ("error" in pRef) { return null; } + let index = Sefaria.index(pRef.index); + return index?.title + }, sectionRef: function(ref, deriveIfNotFound=false) { // Returns the section level ref for `ref` or null if no data is available const oref = this.getRefFromCache(ref); @@ -463,29 +471,61 @@ Sefaria = extend(Sefaria, { }); }, _bulkTexts: {}, + partitionArrayForURL: function(arr, urlMaxLength, dividerToken) { + const result = []; + const dividerTokenLength = encodeURIComponent(dividerToken).length; + let currentPartition = []; + let currentLength = 0; + + for (let i = 0; i < arr.length; i++) { + // Calculate the length of the new item when added to the partition + const item = arr[i]; + const encodedItem = encodeURIComponent(item); + const newLength = currentPartition.length === 0 + ? encodedItem.length + : currentLength + encodedItem.length + dividerTokenLength; // consider dividerToken length + + // Check if adding this item exceeds the max length + if (newLength > urlMaxLength) { + // If it does, push the current partition to the result and start a new one + result.push(currentPartition); + currentPartition = []; + currentLength = 0; + currentPartition.push(item); + continue + } + + // Add the item to the current partition + currentPartition.push(item); + currentLength = newLength; + } + + // Add the last partition to the result + if (currentPartition.length > 0) { + result.push(currentPartition); + } + + return result; +}, + getBulkText: function(refs, asSizedString=false, minChar=null, maxChar=null, transLangPref=null) { if (refs.length === 0) { return Promise.resolve({}); } const MAX_URL_LENGTH = 3800; - const hostStr = `${Sefaria.apiHost}/api/bulktext/`; + const ASSUMED_HOSTNAME_LENGTH_BOUND = 50; + const hostStr = encodeURI(`${Sefaria.apiHost}/api/bulktext/`); let paramStr = ''; for (let [paramKey, paramVal] of Object.entries({asSizedString, minChar, maxChar, transLangPref})) { paramStr = !!paramVal ? paramStr + `&${paramKey}=${paramVal}` : paramStr; } paramStr = paramStr.replace(/&/,'?'); + paramStr = encodeURI(paramStr); // Split into multiple requests if URL length goes above limit - let refStrs = [""]; - refs.map(ref => { - let last = refStrs[refStrs.length-1]; - const encodedFullURL = encodeURI(`${hostStr}${last}|${ref}${paramStr}`); - if (encodedFullURL.length > MAX_URL_LENGTH) { - refStrs.push(ref) - } else { - refStrs[refStrs.length-1] += last.length ? `|${ref}` : ref; - } - }); + const limit = MAX_URL_LENGTH-(hostStr+paramStr).length-ASSUMED_HOSTNAME_LENGTH_BOUND + const refsSubArrays = this.partitionArrayForURL( refs, limit, '|'); + const refStrs = refsSubArrays.map(refsSubArray => refsSubArray.join('|')); let promises = refStrs.map(refStr => this._cachedApiPromise({ url: `${hostStr}${encodeURIComponent(refStr)}${paramStr}`, @@ -2779,6 +2819,56 @@ _media: {}, return Sefaria.topic_toc.filter(x => x.slug == slug).length > 0; }, sheets: { + getSheetsByRef: function(srefs, callback) { + return Sefaria._cachedApiPromise({ + url: `${Sefaria.apiHost}/api/sheets/ref/${srefs}?include_collections=1`, + key: `include_collections|${srefs}`, + store: Sefaria.sheets._sheetsByRef, + processor: callback + }); + }, + sheetsWithRefFilterNodes(sheets) { + /* + This function is used to generate the SearchState with its relevant + FilterNodes to be used by SheetsWithRef for filtering sheets by topic and collection + */ + const newFilter = (item, type) => { + let title, heTitle; + if (type === 'topics') { + [title, heTitle] = [item.en, item.he]; + type = 'topics_en'; + } + else if (type === 'collections') { + [title, heTitle] = [item.name, item.name]; + } + return { + title, heTitle, + docCount: 0, aggKey: item.slug, + selected: 0, aggType: type, + }; + } + + let filters = {}; + sheets.forEach(sheet => { + let slugsFound = new Set(); // keep track of slugs in this sheet\n + ['topics', 'collections'].forEach(itemsType => { + sheet[itemsType]?.forEach(item => { + const key = `${item.slug}|${itemsType}`; + if (!slugsFound.has(key)) { // we don't want to increase docCount when one sheet already + // has a topic/collection with the same slug as the current topic/collection + let filter = filters[key]; + if (!filter) { + filter = newFilter(item, itemsType); + filters[key] = filter; + } + slugsFound.add(key); + filter.docCount += 1; + } + }) + }) + }) + return Object.values(filters).map(f => new FilterNode(f));; + }, _loadSheetByID: {}, loadSheetByID: function(id, callback, reset) { if (reset) { @@ -2943,10 +3033,10 @@ _media: {}, }, sheetsTotalCount: function(refs) { // Returns the total number of private and public sheets on `refs` without double counting my public sheets. - var sheets = Sefaria.sheets.sheetsByRef(refs) || []; + let sheets = Sefaria.sheets.sheetsByRef(refs) || []; if (Sefaria._uid) { - var mySheets = Sefaria.sheets.userSheetsByRef(refs) || []; - sheets = sheets.filter(function(sheet) { return sheet.owner !== Sefaria._uid }).concat(mySheets); + const mySheets = Sefaria.sheets.userSheetsByRef(refs) || []; + sheets = mySheets.concat(sheets.filter(function(sheet) { return sheet.owner !== Sefaria._uid })); } return sheets.length; }, diff --git a/static/js/sefaria/sheetsUtils.js b/static/js/sefaria/sheetsUtils.js new file mode 100644 index 0000000000..850f9167b4 --- /dev/null +++ b/static/js/sefaria/sheetsUtils.js @@ -0,0 +1,69 @@ +import Sefaria from "./sefaria"; + +export async function getSegmentObjs(refs) { + /* + Given an array of ref-strings (could also be ranged refs), + turn each ref to segment object and return an array of all segments + */ + const segments = []; + + for (const ref of refs) { + const text = await Sefaria.getText(ref, { stripItags: 1 }); + const newSegments = Sefaria.makeSegments(text, false); + segments.push(...newSegments); + } + return segments; +} +export async function getNormalRef(ref) { + /* + Given a ref-string, get he and en normal ref-string + */ + const refObj = await Sefaria.getRef(ref); + return {en: refObj.ref, he: refObj.heRef} +} +function placedSegmentMapper(lang, segmented, includeNumbers, s) { + /* + Map each segment object to a formatted text string + */ + if (!s[lang]) {return ""} + + let numStr = ""; + if (includeNumbers) { + const num = (lang=="he") ? Sefaria.hebrew.encodeHebrewNumeral(s.number) : s.number; + numStr = "(" + num + ") "; + } + let str = "" + numStr + s[lang] + " "; + if (segmented) { + str = "

" + str + "

"; + } + str = str.replace(/()+/g, ' ') + return str; +} +export const segmentsToSourceText = (segments, lan) => { + /* + Turn array of segment objects into one chunk of formatted text + */ + const segmented = shouldBeSegmented(segments[0].ref); + const includeNumbers = shouldIncludeSegmentNums(segments[0].ref); + return(segments.map(placedSegmentMapper.bind(this, lan, segmented, includeNumbers)) + .filter(Boolean) + .join("")); +} +function shouldIncludeSegmentNums(ref){ + /* + Decide if segment of this ref should have segment numbers when turned into text chunk + */ + const indexTitle = Sefaria.refIndexTitle(ref); + const categories = Sefaria.refCategories(ref); + if (categories.includes("Talmud")) {return false} + if (indexTitle === "Pesach Haggadah") {return false} + if (categories === 1) {return false} + return true; +} +function shouldBeSegmented(ref){ + /* + Decide if segment of this ref should be followed by new line when turned into text chunk + */ + const categories = Sefaria.refCategories(ref); + return !(categories[0] in {"Tanakh": 1, "Talmud": 1}); +} \ No newline at end of file diff --git a/static/js/sheets/SheetsWithRefPage.jsx b/static/js/sheets/SheetsWithRefPage.jsx new file mode 100644 index 0000000000..b53a4ad5f4 --- /dev/null +++ b/static/js/sheets/SheetsWithRefPage.jsx @@ -0,0 +1,196 @@ +import SearchPage from "../SearchPage"; +import Sefaria from "../sefaria/sefaria"; +import {useEffect, useState} from "react"; +import {SearchTotal} from "../sefaria/searchTotal"; +import SearchState from "../sefaria/searchState"; +const SheetsWithRefPage = ({srefs, searchState, updateSearchState, updateAppliedFilter, + updateAppliedOptionField, updateAppliedOptionSort, onResultClick, + registerAvailableFilters}) => { + const [sheets, setSheets] = useState([]); + const [loading, setLoading] = useState(true); + + const [origAvailableFilters, setOrigAvailableFilters] = useState([]); + // storing original available filters is crucial so that we have access to the full list of filters. + // by contrast, in the searchState, the available filters length changes based on filtering. + // by having access to the original available filters list, if the searchState's applied filters are turned off, + // we can return the searchState's available filters to the original list. Once origAvailableFilters are loaded, + // they are never changed. + + const [refs, setRefs] = useState(srefs); + const sortTypeArray = SearchState.metadataByType['sheet'].sortTypeArray.filter(sortType => sortType.type !== 'relevance'); + + const cloneFilters = (availableFilters, resetDocCounts = true) => { + // clone filters so that we can update the available filters docCounts + // without modifying the original available filters (origAvailableFilters) docCounts + // we don't want to modify the origAvailableFilters docCounts so that we have the accurate number when checking + // in checkForRegisteringAvailableFilters + return availableFilters.map(availableFilter => { + let newAvailableFilter = availableFilter.clone(); + if (resetDocCounts) newAvailableFilter.docCount = 0; + return newAvailableFilter; + }) + } + const getDocCounts = (availableFilters) => { + return availableFilters.map(availableFilter => availableFilter.docCount).sort((a, b) => a - b); + } + const checkForRegisteringAvailableFilters = (availableFilters) => { + const newDocCounts = getDocCounts(availableFilters); + const currDocCounts = getDocCounts(searchState.availableFilters); + if (!newDocCounts.compare(currDocCounts)) { // if previously the appliedFilters were different, + // then the doccounts will be different, so register + availableFilters = availableFilters.sort((a, b) => b.docCount - a.docCount || a.title.localeCompare(b.title)); + registerAvailableFilters('sheet', availableFilters, {}, [], ['collections', 'topics_en']); + } + } + + const getSheetSlugs = (type, sheet) => { + const items = type === 'topics_en' ? sheet.topics : sheet.collections; + return items.map(x => x.slug); + } + const applyFiltersToSheets = (sheets) => { + searchState.appliedFilters.forEach((appliedFilter, i) => { + const type = searchState.appliedFilterAggTypes[i]; + sheets = sheets.filter(sheet => { + const slugs = getSheetSlugs(type, sheet); + return slugs.includes(appliedFilter); + }); + }); + return sheets; + } + + const applyFilters = (sheets) => { + if (searchState.appliedFilters.length === 0) { + checkForRegisteringAvailableFilters(origAvailableFilters); + } + else { + let newAvailableFilters = cloneFilters(origAvailableFilters); + sheets = applyFiltersToSheets(sheets); + newAvailableFilters = updateFilterDocCounts(newAvailableFilters, sheets); + newAvailableFilters = removeEmptyFilters(newAvailableFilters); + newAvailableFilters = updateFilterSelectedValues(newAvailableFilters); + checkForRegisteringAvailableFilters(newAvailableFilters); + } + return sheets; + } + const updateFilterSelectedValues = (availableFilters) => { + availableFilters.forEach((availableFilter) => { + const selected = searchState.appliedFilters.includes(availableFilter.aggKey); + if (selected !== Boolean(availableFilter.selected)) { + if (selected) { + availableFilter.setSelected(true); + } else { + availableFilter.setUnselected(true); + } + } + }) + return availableFilters; + } + const removeEmptyFilters = (availableFilters) => { + return availableFilters.filter(availableFilter => availableFilter.docCount > 0); + } + const updateFilterDocCounts = (availableFilters, sheets) => { + ['collections', 'topics_en'].forEach(type => { + let allSlugs = {}; + sheets.forEach(sheet => { + let slugs = getSheetSlugs(type, sheet); + slugs = [...new Set(slugs)]; // don't double count slugs since there are duplicates + slugs.forEach(slug => { + if (!(slug in allSlugs)) { + allSlugs[slug] = 0; + } + allSlugs[slug] += 1; + }) + }) + availableFilters.forEach((filter, i) => { + if (filter.aggKey in allSlugs && filter.aggType === type) { + availableFilters[i].docCount = allSlugs[filter.aggKey]; + } + }) + }) + return availableFilters; + } + const applySortOption = (sheets) => { + switch(searchState.sortType) { + case 'views': + sheets = sheets.sort((a, b) => b.views - a.views); + break; + case 'dateCreated': + sheets = sheets.sort((a, b) => new Date(b.dateCreated) - new Date(a.dateCreated)); + break; + } + return sheets; + } + const prepSheetsForDisplay = (sheets) => { + sheets = sheets.sort((a, b) => { + // First place user's sheet + if (a.owner === Sefaria.uid && b.owner !== Sefaria.uid) { + return -1; + } + if (a.owner !== Sefaria.uid && b.owner === Sefaria.uid) { + return 1; + } + // Then sort by language / interface language + let aHe, bHe; + [aHe, bHe] = [a.title, b.title].map(Sefaria.hebrew.isHebrew); + if (aHe !== bHe) { return (bHe ? -1 : 1) * (Sefaria.interfaceLang === "hebrew" ? -1 : 1); } + }) + return sheets; + } + const normalizeSheetsMetaData = (sheets) => { + return sheets.map(sheet => { + return { + sheetId: sheet.id, + title: sheet.title, + owner_name: sheet.ownerName, + owner_image: sheet.ownerImageUrl, + profile_url: sheet.ownerProfileUrl, + dateCreated: sheet.dateCreated, + _id: sheet.id, + snippet: sheet.summary || "", + } + }) + } + const handleSheetsLoad = (sheets) => { + searchState.availableFilters = Sefaria.sheets.sheetsWithRefFilterNodes(sheets); + searchState.sortType = "views"; + setSheets(sheets); + updateSearchState(searchState, 'sheet'); + setOrigAvailableFilters(searchState.availableFilters); + setLoading(false); + } + const makeSheetsUnique = (sheets) => { + // if a sheet ID occurs in multiple sheet items, only keep the first sheet found so that there are not duplicates + return sheets.filter((sheet, index, self) => + index === self.findIndex((s) => ( + s.id === sheet.id) + )) + } + + useEffect(() => { + Sefaria.sheets.getSheetsByRef(refs, makeSheetsUnique).then(sheets => {handleSheetsLoad(sheets);}) + }, [refs]); + + let sortedSheets = [...sheets]; + sortedSheets = applyFilters(sortedSheets); + sortedSheets = applySortOption(sortedSheets); + sortedSheets = prepSheetsForDisplay(sortedSheets); + sortedSheets = normalizeSheetsMetaData(sortedSheets); + return +} +export default SheetsWithRefPage; \ No newline at end of file diff --git a/templates/_sidebar.html b/templates/_sidebar.html index 6d6c02785d..0de08cb2b1 100644 --- a/templates/_sidebar.html +++ b/templates/_sidebar.html @@ -13,6 +13,9 @@

{{heTitle}}

  • Jobs at Sefaria משרות פנויות בספריא
  • +
  • + Sefaria's Products + מוצרים של ספריא
  • Our Supporters התומכים שלנו
  • diff --git a/templates/static/products.html b/templates/static/products.html new file mode 100644 index 0000000000..29828f2839 --- /dev/null +++ b/templates/static/products.html @@ -0,0 +1,30 @@ + +{% extends "base.html" %} +{% load i18n static %} + +{% block title %}{% trans "Products" %}{% endblock %} + +{% block description %}{% trans "A catalogue of Sefaria's products and experiments" %}{% endblock %} + +{% block js %} + +{% endblock %} + +{% block content %} +
    + {% if not request.user_agent.is_mobile %} + {% include '_sidebar.html' with whichPage='products' title="Products" heTitle="מוצרים" %} + {% endif %} +
    +
    +
    Loading...
    +
    +
    +
    +{% endblock %} +{% block footer %} {% endblock %}