diff --git a/.github/workflows/integration-testing.yaml b/.github/workflows/integration-testing.yaml index fb9b9cf2eb..887dd2f73f 100644 --- a/.github/workflows/integration-testing.yaml +++ b/.github/workflows/integration-testing.yaml @@ -8,7 +8,7 @@ concurrency: integration_environment jobs: variables: - if: ${{ github.event_name == 'merge_group' }} + if: ${{ github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest outputs: date: ${{ steps.data.outputs.date }} @@ -29,7 +29,7 @@ jobs: echo "current_branch=merge-queue" >> $GITHUB_OUTPUT build-generic: - if: ${{ github.event_name == 'merge_group' }} + if: ${{ github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }} name: "Integration Image Build" needs: - variables @@ -83,7 +83,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-derived: - if: ${{ github.event_name == 'merge_group' }} + if: ${{ github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest name: "Integration Image Build Stage 2" permissions: @@ -140,7 +140,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} run-tests: - if: ${{ github.event_name == 'merge_group' }} + if: ${{ github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }} name: "Playwright" # This name is referenced when slacking status needs: - build-derived @@ -204,7 +204,7 @@ jobs: - name: Uninstall run: helm delete integration-${{ needs.variables.outputs.commit }} -n ${{ secrets.DEV_SANDBOX_NAMESPACE }} --debug --timeout 10m0s ending-notification: - if: ${{ github.event_name == 'merge_group' }} + if: ${{ github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest needs: - run-tests diff --git a/e2e-tests/globals.ts b/e2e-tests/globals.ts index a4b53698f2..959dd8b4a6 100644 --- a/e2e-tests/globals.ts +++ b/e2e-tests/globals.ts @@ -5,6 +5,12 @@ export const LANGUAGES = { 'HE': 'hebrew', } +export const SOURCE_LANGUAGES = { + 'EN': /^(תרגום|Translation)$/, + 'HE': /^(מקור|Source)$/, + 'BI': /^(מקור ותרגום|Source with Translation)$/ +} + export const cookieObject = { "name": "interfaceLang", "value": DEFAULT_LANGUAGE, diff --git a/e2e-tests/tests/interface-language-is-sticky.spec.ts b/e2e-tests/tests/interface-language-is-sticky.spec.ts new file mode 100644 index 0000000000..682d48ee93 --- /dev/null +++ b/e2e-tests/tests/interface-language-is-sticky.spec.ts @@ -0,0 +1,57 @@ +import {test, expect} from '@playwright/test'; +import {changeLanguageOfText, goToPageWithLang, isIsraelIp} from "../utils"; +import {LANGUAGES, SOURCE_LANGUAGES} from '../globals' + +const interfaceTextHE = 'מקורות'; +const interfaceTextEN = 'Texts'; + +[ + // Hebrew Interface and English Source + {interfaceLanguage: 'Hebrew', interfaceLanguageToggle: LANGUAGES.HE, + sourceLanguage: 'English', sourceLanguageToggle: SOURCE_LANGUAGES.EN, + expectedSourceText: 'When God began to create', expectedBilingualText: '', expectedInterfaceText: interfaceTextHE }, + + // Hebrew Interface and Bilingual Source + {interfaceLanguage: 'Hebrew', interfaceLanguageToggle: LANGUAGES.HE, + sourceLanguage: 'Bilingual', sourceLanguageToggle: SOURCE_LANGUAGES.BI, + expectedSourceText: 'רֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ׃', expectedBilingualText: 'When God began to create', expectedInterfaceText: interfaceTextHE }, + + // Hebrew Interface and Hebrew Source + {interfaceLanguage: 'Hebrew', interfaceLanguageToggle: LANGUAGES.HE, + sourceLanguage: 'Hebrew', sourceLanguageToggle: SOURCE_LANGUAGES.HE, + expectedSourceText: 'רֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ׃', expectedBilingualText: '', expectedInterfaceText: interfaceTextHE }, + + // English Interface and English Source + {interfaceLanguage: 'English', interfaceLanguageToggle: LANGUAGES.EN, + sourceLanguage: 'English', sourceLanguageToggle: SOURCE_LANGUAGES.EN, + expectedSourceText: 'When God began to create', expectedBilingualText: '', expectedInterfaceText: interfaceTextEN }, + + // English Interface and Bilingual Source + {interfaceLanguage: 'English', sinterfaceLanguageToggle: LANGUAGES.EN, + sourceLanguage: 'Bilingual', sourceLanguageToggle: SOURCE_LANGUAGES.BI, + expectedSourceText: 'רֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ׃', expectedBilingualText: 'When God began to create', + expectedInterfaceText: interfaceTextEN }, + + // English Interface and Hebrew Source + {interfaceLanguage: 'English', interfaceLanguageToggle: LANGUAGES.EN, + sourceLanguage: 'Hebrew', sourceLanguageToggle: SOURCE_LANGUAGES.HE, + expectedSourceText: 'רֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ׃', expectedBilingualText: '', expectedInterfaceText: interfaceTextEN } + +].forEach(({interfaceLanguage, interfaceLanguageToggle, sourceLanguage, sourceLanguageToggle, expectedSourceText, expectedBilingualText, expectedInterfaceText}) => { + test(`${interfaceLanguage} Interface Language with ${sourceLanguage} Source`, async ({ context }) => { + + // Navigating to Bereshit with selected Interface Language, Hebrew or English + const page = await goToPageWithLang(context,'/Genesis.1',`${interfaceLanguageToggle}`) + + // Selecting Source Language + await changeLanguageOfText(page, sourceLanguageToggle) + + // Locating the source text segment, then verifying translation + await expect(page.locator('div.segmentNumber').first().locator('..').locator('p')).toContainText(`${expectedSourceText}`) + + // Validate Hebrew interface language is still toggled + const textLink = page.locator('a.textLink').first() + await expect(textLink).toHaveText(`${expectedInterfaceText}`) + + }) +}) \ No newline at end of file diff --git a/e2e-tests/tests/reader.spec.ts b/e2e-tests/tests/reader.spec.ts index 3d424d4065..9ed0ae1e6e 100644 --- a/e2e-tests/tests/reader.spec.ts +++ b/e2e-tests/tests/reader.spec.ts @@ -15,14 +15,14 @@ test('Navigate to bereshit', async ({ context }) => { }); test('Verify translations', async ({ context }) => { - const page = await goToPageWithLang(context, '/Berakhot.28b.4?vhe=Wikisource_Talmud_Bavli&lang=bi&with=all&lang2=he'); + const page = await goToPageWithLang(context, '/Berakhot.28b.4?vhe=hebrew|Wikisource_Talmud_Bavli&lang=bi&with=all&lang2=he'); await page.getByRole('link', { name: 'Translations (4)' }).click(); await page.locator('#panel-1').getByText('Loading...').waitFor({ state: 'detached' }); page.getByText('A. Cohen, Cambridge University Press, 1921', { exact: true }) }); test('Get word description', async ({ context }) => { - const page = await goToPageWithLang(context, '/Berakhot.28b.4?vhe=Wikisource_Talmud_Bavli&lang=bi&with=all&lang2=he'); + const page = await goToPageWithLang(context, '/Berakhot.28b.4?vhe=hebrew|Wikisource_Talmud_Bavli&lang=bi&with=all&lang2=he'); await page.getByRole('link', { name: 'ר\' נחוניא בן הקנה' }).click(); await page.locator('#panel-1').getByText('Loading...').waitFor({ state: 'detached' }); await page.getByText('Looking up words...').waitFor({ state: 'detached' }); @@ -31,11 +31,11 @@ test('Get word description', async ({ context }) => { test('Open panel window', async ({ context }) => { - const page = await goToPageWithLang(context, '/Berakhot.28b.4?vhe=Wikisource_Talmud_Bavli&lang=bi&with=all&lang2=he'); + const page = await goToPageWithLang(context, '/Berakhot.28b.4?vhe=hebrew|Wikisource_Talmud_Bavli&&lang=bi&with=all&lang2=he'); await page.getByText('ולית הלכתא לא כרב הונא ולא כריב"ל כרב הונא הא דאמרן כריב"ל דאריב"ל כיון שהגיע זמ').click(); await page.locator('#panel-1').getByText('Loading...').waitFor({ state: 'detached' }); await page.getByRole('link', { name: 'תלמוד (1)' }).click(); - await page.getByRole('link', { name: 'שבת (1) מלאכות האסורות בשבת ודינים הקשורים לקדושת היום.' }).click(); + await page.getByRole('link', { name: /^שבת/ }).click(); await page.getByText('טעינה...').waitFor({ state: 'detached' }); await page.getByRole('link', { name: 'Open' }).click(); await page.getByRole('heading', { name: 'Loading...' }).getByText('Loading...').waitFor({ state: 'detached' }); diff --git a/e2e-tests/tests/translation-version-name-appears-in-title.spec.ts b/e2e-tests/tests/translation-version-name-appears-in-title.spec.ts new file mode 100644 index 0000000000..46bb51ce58 --- /dev/null +++ b/e2e-tests/tests/translation-version-name-appears-in-title.spec.ts @@ -0,0 +1,80 @@ +import {test, expect} from '@playwright/test'; +import {goToPageWithLang, changeLanguageOfText} from "../utils"; +import {LANGUAGES, SOURCE_LANGUAGES} from '../globals' + +[ + // Hebrew Interface and Hebrew Source + {interfaceLanguage: 'Hebrew', interfaceLanguageToggle: LANGUAGES.HE, + sourceLanguage: 'Hebrew', sourceLanguageToggle: SOURCE_LANGUAGES.HE, + translations: 'תרגומים', select: 'בחירה', currentlySelected: 'נוכחי'}, + + // Hebrew Interface and Bilingual Source + {interfaceLanguage: 'Hebrew', interfaceLanguageToggle: LANGUAGES.HE, + sourceLanguage: 'Bilingual', sourceLanguageToggle: SOURCE_LANGUAGES.BI, + translations: 'תרגומים', select: 'בחירה', currentlySelected: 'נוכחי'}, + + // Hebrew Interface and English Source + {interfaceLanguage: 'Hebrew', interfaceLanguageToggle: LANGUAGES.HE, + sourceLanguage: 'English', sourceLanguageToggle: SOURCE_LANGUAGES.EN, + translations: 'תרגומים', select: 'בחירה', currentlySelected: 'נוכחי'}, + + // English Interface and English Source + {interfaceLanguage: 'English', interfaceLanguageToggle: LANGUAGES.EN, + sourceLanguage: 'English', sourceLanguageToggle: SOURCE_LANGUAGES.EN, + translations: 'Translations', select: 'Select', currentlySelected: 'Currently Selected'}, + + // English Interface and English Source + {interfaceLanguage: 'English', interfaceLanguageToggle: LANGUAGES.EN, + sourceLanguage: 'Bilingual', sourceLanguageToggle: SOURCE_LANGUAGES.BI, + translations: 'Translations', select: 'Select', currentlySelected: 'Currently Selected'}, + + // English Interface and Hebrew Source + {interfaceLanguage: 'English', interfaceLanguageToggle: LANGUAGES.EN, + sourceLanguage: 'Hebrew', sourceLanguageToggle: SOURCE_LANGUAGES.HE, + translations: 'Translations', select: 'Select', currentlySelected: 'Currently Selected'} + +].forEach(({interfaceLanguage, interfaceLanguageToggle, sourceLanguage, sourceLanguageToggle, translations, currentlySelected, select}) => { + test(`${interfaceLanguage} - translation name appears in title for ${sourceLanguage} source text`, async ({ context }) => { + // Navigate to Bereshit in specified Interface Language + const page = await goToPageWithLang(context,'/Genesis.1', `${interfaceLanguageToggle}`) + + // Change the Source Language of the text + await changeLanguageOfText(page, sourceLanguageToggle) + + // Retain the translation name locator + const translationNameInTitle = page.locator('span.readerTextVersion') + + // Navigate to the Translations sidebar by clicking on the text title + //Clicks on בראשית א׳ / Genesis I + await page.locator('h1').click() + + // Click on Translations + await page.getByRole('link', {name: `${translations}`}).click() + + // Wait for Translations side-bar to load by waiting for 'Translations' header + await page.waitForSelector('h3') + + // Check if the default translation in the title matches the selected translation + // NOTE: We are skipping checking for the default translation here, due to the Hebrew text being default Masoretic + if(sourceLanguage !== 'Hebrew'){ + const defaultTranslation = await translationNameInTitle.textContent() + await expect(page.locator('div.version-with-preview-title-line', {hasText: defaultTranslation!}).getByRole('link')).toHaveText(`${currentlySelected}`) + } + + // TODO: 4th translation, handling Hebrew Interface translations in Hebrew. For example: 'חומש רש״י, רבי שרגא זילברשטיין' should appear in the translation title as written. + const translationNames = ['The Schocken Bible, Everett Fox, 1995 ©', '«Да» project'] + + // Utilizing the traditional for-loop as there are async issues with foreach + for(let i = 0; i < translationNames.length; i++){ + + // "Select" another translation. + await page.locator('div.version-with-preview-title-line', {hasText: translationNames[i]}).getByText(`${select}`).click() + + // Validate selected translation is reflected in title + await expect(translationNameInTitle).toHaveText(translationNames[i]) + + // Validate selected translation says 'Currently Selected' + await expect(page.locator('div.version-with-preview-title-line', {hasText: translationNames[i]}).getByRole('link')).toHaveText(`${currentlySelected}`) + } + }) +}); \ No newline at end of file diff --git a/e2e-tests/utils.ts b/e2e-tests/utils.ts index dc3922d64e..22d75a0270 100644 --- a/e2e-tests/utils.ts +++ b/e2e-tests/utils.ts @@ -1,4 +1,4 @@ -import {DEFAULT_LANGUAGE, LANGUAGES, testUser} from './globals' +import {DEFAULT_LANGUAGE, LANGUAGES, SOURCE_LANGUAGES, testUser} from './globals' import {BrowserContext} from 'playwright-core'; import type { Page } from 'playwright-core'; @@ -23,13 +23,16 @@ export const changeLanguage = async (page: Page, language: string) => { } export const goToPageWithLang = async (context: BrowserContext, url: string, language=DEFAULT_LANGUAGE) => { - if (!langCookies.length) { - const page: Page = await context.newPage(); - await page.goto(''); - await changeLanguage(page, language); - langCookies = await context.cookies(); - } + // If a cookie already has contents, clear it so that the language cookie can be reset + if (langCookies.length) { + await context.clearCookies() + } + const page: Page = await context.newPage(); + await page.goto(''); + await changeLanguage(page, language); + langCookies = await context.cookies(); await context.addCookies(langCookies); + // this is a hack to get the cookie to work const newPage: Page = await context.newPage(); await newPage.goto(url); @@ -65,4 +68,26 @@ export const goToPageWithUser = async (context: BrowserContext, url: string, use export const getPathAndParams = (url: string) => { const urlObj = new URL(url); return urlObj.pathname + urlObj.search; +} + +export const changeLanguageOfText = async (page: Page, sourceLanguage: RegExp) => { + // Clicking on the Source Language toggle + await page.getByAltText('Toggle Reader Menu Display Settings').click() + + // Selecting Source Language + await page.locator('div').filter({ hasText: sourceLanguage }).click() +} + +export const getCountryByIp = async (page: Page) => { + const data = await page.evaluate(() => { + return fetch('https://ipapi.co/json/') + .then(response => response.json()) + .then(data => data) + }) + return data.country; +} + +export const isIsraelIp = async (page: Page) => { + const country = await getCountryByIp(page); + return country === "IL"; } \ No newline at end of file diff --git a/reader/views.py b/reader/views.py index 418a67aee6..3da1370813 100644 --- a/reader/views.py +++ b/reader/views.py @@ -34,6 +34,7 @@ from sefaria.model import * from sefaria.google_storage_manager import GoogleStorageManager +from sefaria.model.text_reuqest_adapter import TextRequestAdapter from sefaria.model.user_profile import UserProfile, user_link, public_user_data, UserWrapper from sefaria.model.collection import CollectionSet from sefaria.model.webpage import get_webpages_for_ref @@ -280,6 +281,18 @@ def user_credentials(request): return {"user_type": "API", "user_id": apikey["uid"]} +def _reader_redirect_add_languages(request, tref): + versions = Ref(tref).version_list() + query_params = QueryDict(mutable=True) + for vlang, direction in [('ven', 'ltr'), ('vhe', 'rtl')]: + version_title = request.GET.get(vlang) + if version_title: + version_title = version_title.replace('_', ' ') + version = next((v for v in versions if v['direction'] == direction and v['versionTitle'] == version_title)) + query_params[vlang] = f'{version["languageFamilyName"]}|{version["versionTitle"]}' + return redirect(f'/{tref}/?{urllib.parse.urlencode(query_params)}') + + @ensure_csrf_cookie def catchall(request, tref, sheet=None): """ @@ -295,6 +308,10 @@ def reader_redirect(uref): response['Location'] += "?%s" % params if params else "" return response + for version in ['ven', 'vhe']: + if request.GET.get(version) and '|' not in request.GET.get(version): + return _reader_redirect_add_languages(request, tref) + if sheet is None: try: oref = Ref.instantiate_ref_with_legacy_parse_fallback(tref) @@ -320,7 +337,7 @@ def old_versions_redirect(request, tref, lang, version): def get_connections_mode(filter): # List of sidebar modes that can function inside a URL parameter to open the sidebar in that state. - sidebarModes = ("Sheets", "Notes", "About", "AboutSheet", "Navigation", "Translations", "Translation Open","WebPages", "extended notes", "Topics", "Torah Readings", "manuscripts", "Lexicon", "SidebarSearch", "Guide") + sidebarModes = ("Sheets", "Notes", "About", "AboutSheet", "Navigation", "Translations", "Translation Open", "Version Open", "WebPages", "extended notes", "Topics", "Torah Readings", "manuscripts", "Lexicon", "SidebarSearch", "Guide") if filter[0] in sidebarModes: return filter[0], True elif filter[0].endswith(" ConnectionsList"): @@ -330,7 +347,7 @@ def get_connections_mode(filter): else: return "TextList", False -def make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, mode, **kwargs): +def make_panel_dict(oref, primaryVersion, translationVersion, filter, versionFilter, mode, **kwargs): """ Returns a dictionary corresponding to the React panel state, additionally setting `text` field with textual content. @@ -338,15 +355,8 @@ def make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, mode, **k if oref.is_book_level(): index_details = library.get_index(oref.normal()).contents(with_content_counts=True) index_details["relatedTopics"] = get_topics_for_book(oref.normal(), annotate=True) - if kwargs.get('extended notes', 0) and (versionEn is not None or versionHe is not None): - currVersions = {"en": versionEn, "he": versionHe} - if versionEn is not None and versionHe is not None: - curr_lang = kwargs.get("panelDisplayLanguage", "en") - for key in list(currVersions.keys()): - if key == curr_lang: - continue - else: - currVersions[key] = None + if kwargs.get('extended notes', 0): + currVersions = {"en": translationVersion, "he": primaryVersion} panel = { "menuOpen": "extended notes", "mode": "Menu", @@ -369,10 +379,7 @@ def make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, mode, **k "mode": mode, "ref": oref.normal(), "refs": [oref.normal()] if not oref.is_spanning() else [r.normal() for r in oref.split_spanning_ref()], - "currVersions": { - "en": versionEn, - "he": versionHe, - }, + "currVersions": {"en": translationVersion, "he": primaryVersion}, "filter": filter, "versionFilter": versionFilter, } @@ -403,15 +410,29 @@ def make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, mode, **k if settings_override: panel["settings"] = settings_override if mode != "Connections" and oref != None: - try: - text_family = TextFamily(oref, version=panel["currVersions"]["en"], lang="en", version2=panel["currVersions"]["he"], lang2="he", commentary=False, - context=True, pad=True, alts=True, wrapLinks=False, translationLanguagePreference=kwargs.get("translationLanguagePreference", None)).contents() - except NoVersionFoundError: - text_family = {} - text_family["updateFromAPI"] = True - text_family["next"] = oref.next_section_ref().normal() if oref.next_section_ref() else None - text_family["prev"] = oref.prev_section_ref().normal() if oref.prev_section_ref() else None - panel["text"] = text_family + primary_params = [primaryVersion['languageFamilyName'], primaryVersion['versionTitle']] + primary_params[0] = primary_params[0] or 'primary' + translation_params = [translationVersion['languageFamilyName'], translationVersion['versionTitle']] + translation_params[0] = translation_params[0] or 'translation' + text_adapter = TextRequestAdapter(oref.section_ref(), [primary_params, translation_params], return_format='wrap_all_entities') + text = text_adapter.get_versions_for_query() + # text['ref'] = oref.normal() + #now we we should add the he and text attributes + if len(text['versions']) == 2: + if text['versions'][0]['isPrimary'] and not text['versions'][1]['isSource']: + text['he'], text['text'] = text['versions'][0]['text'], text['versions'][1]['text'] + else: + text['he'], text['text'] = text['versions'][1]['text'], text['versions'][0]['text'] + elif len(text['versions']) == 1: + if primary_params == translation_params: + text['he'] = text['text'] = text['versions'][0]['text'] + elif [text['versions'][0]['languageFamilyName'], text['versions'][0]['versionTitle']] == translation_params: + text['text'], text['he'] = text['versions'][0]['text'], [] + else: + text['he'], text['text'] = text['versions'][0]['text'], [] + + text["updateFromAPI"] = True + panel["text"] = text if oref.index.categories == ["Tanakh", "Torah"]: panel["indexDetails"] = oref.index.contents() # Included for Torah Parashah titles rendered in text @@ -490,7 +511,7 @@ def make_sheet_panel_dict(sheet_id, filter, **kwargs): return panels -def make_panel_dicts(oref, versionEn, versionHe, filter, versionFilter, multi_panel, **kwargs): +def make_panel_dicts(oref, primaryVersion, translationVersion, filter, versionFilter, multi_panel, **kwargs): """ Returns an array of panel dictionaries. Depending on whether `multi_panel` is True, connections set in `filter` are displayed in either 1 or 2 panels. @@ -498,16 +519,22 @@ def make_panel_dicts(oref, versionEn, versionHe, filter, versionFilter, multi_pa panels = [] # filter may have value [], meaning "all". Therefore we test filter with "is not None". if filter is not None and multi_panel: - panels += [make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, "Text", **kwargs)] - panels += [make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, "Connections", **kwargs)] + panels += [make_panel_dict(oref, primaryVersion, translationVersion, filter, versionFilter, "Text", **kwargs)] + panels += [make_panel_dict(oref, primaryVersion, translationVersion, filter, versionFilter, "Connections", **kwargs)] elif filter is not None and not multi_panel: - panels += [make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, "TextAndConnections", **kwargs)] + panels += [make_panel_dict(oref, primaryVersion, translationVersion, filter, versionFilter, "TextAndConnections", **kwargs)] else: - panels += [make_panel_dict(oref, versionEn, versionHe, filter, versionFilter, "Text", **kwargs)] + panels += [make_panel_dict(oref, primaryVersion, translationVersion, filter, versionFilter, "Text", **kwargs)] return panels +def _extract_version_params(request, key): + params = request.GET.get(key, '|') + params = params.replace("_", " ") + languageFamilyName, versionTitle = params.split('|') + return {'languageFamilyName': languageFamilyName, 'versionTitle': versionTitle} + @sanitize_get_params def text_panels(request, ref, version=None, lang=None, sheet=None): """ @@ -525,12 +552,8 @@ def text_panels(request, ref, version=None, lang=None, sheet=None): panels = [] multi_panel = not request.user_agent.is_mobile and not "mobile" in request.GET # Handle first panel which has a different signature in params - versionEn = request.GET.get("ven", None) - if versionEn: - versionEn = versionEn.replace("_", " ") - versionHe = request.GET.get("vhe", None) - if versionHe: - versionHe = versionHe.replace("_", " ") + primaryVersion = _extract_version_params(request, 'vhe') + translationVersion = _extract_version_params(request, 'ven') filter = request.GET.get("with").replace("_", " ").split("+") if request.GET.get("with") else None filter = [] if filter == ["all"] else filter @@ -540,11 +563,7 @@ def text_panels(request, ref, version=None, lang=None, sheet=None): if sheet == None: versionFilter = [request.GET.get("vside").replace("_", " ")] if request.GET.get("vside") else [] - if versionEn and not Version().load({"versionTitle": versionEn, "language": "en"}): - raise Http404 - if versionHe and not Version().load({"versionTitle": versionHe, "language": "he"}): - raise Http404 - versionEn, versionHe = override_version_with_preference(oref, request, versionEn, versionHe) + # versionEn, versionHe = override_version_with_preference(oref, request, versionEn, versionHe) #TODO kwargs = { "panelDisplayLanguage": request.GET.get("lang", request.contentLang), @@ -564,7 +583,7 @@ def text_panels(request, ref, version=None, lang=None, sheet=None): kwargs["sidebarSearchQuery"] = request.GET.get("sbsq", None) kwargs["selectedNamedEntity"] = request.GET.get("namedEntity", None) kwargs["selectedNamedEntityText"] = request.GET.get("namedEntityText", None) - panels += make_panel_dicts(oref, versionEn, versionHe, filter, versionFilter, multi_panel, **kwargs) + panels += make_panel_dicts(oref, primaryVersion, translationVersion, filter, versionFilter, multi_panel, **kwargs) elif sheet == True: panels += make_sheet_panel_dict(ref, filter, **{"panelDisplayLanguage": request.GET.get("lang",request.contentLang), "referer": request.path}) diff --git a/sefaria/datatype/jagged_array.py b/sefaria/datatype/jagged_array.py index 5d35642afb..3957960061 100644 --- a/sefaria/datatype/jagged_array.py +++ b/sefaria/datatype/jagged_array.py @@ -659,12 +659,13 @@ def modify_by_function(self, func, start_sections=None, _cur=None, _curSections= Func should accept two parameters: 1) text of current segment 2) zero-indexed indices of segment :param start_sections: array(int), optional param. Sections passed to `func` will be offset by `start_sections`, if passed """ - _curSections = _curSections or [] if _cur is None: _cur = self._store if isinstance(_cur, str): + _curSections = _curSections or [0] return func(_cur, self.get_offset_sections(_curSections, start_sections)) elif isinstance(_cur, list): + _curSections = _curSections or [] return [self.modify_by_function(func, start_sections, temp_curr, _curSections + [i]) for i, temp_curr in enumerate(_cur)] def flatten_to_array(self, _cur=None): diff --git a/sefaria/model/text_reuqest_adapter.py b/sefaria/model/text_reuqest_adapter.py index d98aa31ba1..3030522300 100644 --- a/sefaria/model/text_reuqest_adapter.py +++ b/sefaria/model/text_reuqest_adapter.py @@ -98,7 +98,6 @@ def _add_ref_data_to_return_obj(self) -> None: 'heSectionRef': oref.section_ref().he_normal(), 'firstAvailableSectionRef': oref.first_available_section_ref().normal(), 'isSpanning': oref.is_spanning(), - 'spanningRefs': [r.normal() for r in oref.split_spanning_ref()], 'next': oref.next_section_ref().normal() if oref.next_section_ref() else None, 'prev': oref.prev_section_ref().normal() if oref.prev_section_ref() else None, 'title': oref.context_ref().normal(), @@ -107,6 +106,8 @@ def _add_ref_data_to_return_obj(self) -> None: 'primary_category': oref.primary_category, 'type': oref.primary_category, #same as primary category }) + if self.return_obj['isSpanning']: + self.return_obj['spanningRefs'] = [r.normal() for r in oref.split_spanning_ref()] def _add_index_data_to_return_obj(self) -> None: index = self.oref.index diff --git a/sourcesheets/views.py b/sourcesheets/views.py index 1340dc9111..41e3a2ff73 100644 --- a/sourcesheets/views.py +++ b/sourcesheets/views.py @@ -2,7 +2,7 @@ import json import httplib2 from urllib3.exceptions import NewConnectionError -from urllib.parse import unquote +from urllib.parse import unquote, urlencode from elasticsearch.exceptions import AuthorizationException from datetime import datetime from io import StringIO, BytesIO @@ -193,6 +193,15 @@ def view_sheet(request, sheet_id, editorMode = False): editor = request.GET.get('editor', '0') embed = request.GET.get('embed', '0') + interface_lang = request.interfaceLang + content_lang = request.GET.get("lang", request.contentLang) + fixed_content_lang = 'he' if interface_lang == 'hebrew' else 'bi' + if content_lang != fixed_content_lang: + query_params = request.GET.dict() + query_params['lang'] = fixed_content_lang + new_url = f"/sheets/{sheet_id}?{urlencode(query_params)}" + return redirect(new_url, permanent=True) + if editor != '1' and embed !='1' and editorMode is False: return catchall(request, sheet_id, True) diff --git a/static/css/s2.css b/static/css/s2.css index c06d921d2b..62afbc7436 100644 --- a/static/css/s2.css +++ b/static/css/s2.css @@ -1485,7 +1485,8 @@ div.interfaceLinks-row a { .readerPanel .content { direction: ltr; /* Even in Hebrew Interface, we want scroll bars on the right */ } -.readerPanel .he { +.readerPanel .he, +.readerPanel .content .rtl { direction: rtl; text-align: right; unicode-bidi: initial; @@ -1519,9 +1520,19 @@ div.interfaceLinks-row a { .readerPanel.bilingual .readerNavMenu .gridBox { direction: ltr; } -.readerPanel.english .he { +.readerPanel.english .contentText .he, +.readerPanel.hebrew .contentText .en, +.readerPanel.english .contentSpan.primary, +.readerPanel.english .languageToggle .he, +.readerPanel.hebrew .contentSpan.translation, +.readerPanel.hebrew .languageToggle .en { display: none; } +.readerPanel.english .versionsTextList .primary, +.readerPanel.hebrew .versionsTextList .translation { + display: block; +} + .readerPanel.english .he.heOnly{ display: inline; text-align: right; @@ -1533,11 +1544,10 @@ div.interfaceLinks-row a { display: inline; text-align: right; } -.readerPanel.hebrew .en { - display: none; -} .readerPanel.english .heOnly .he, -.readerPanel.bilingual .heOnly .he { +.readerPanel.bilingual .heOnly .he, +.readerPanel.english .enOnly .en, +.readerPanel.bilingual .enOnly .en { display: inline; } .languageToggle { @@ -4893,6 +4903,10 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .tagsList .enOnly { direction: ltr; } +.readerControlsOuter { + position: relative; + z-index: 103; +} .readerControls { position: relative; top: 0; @@ -4900,7 +4914,6 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus width: 100%; box-sizing: border-box; text-align: center; - z-index: 100; height: 60px; line-height: 60px; box-shadow: 0 1px 3px rgba(0,0,0,0.2); @@ -4929,13 +4942,14 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus background-color: #EDEDED; } .readerControls .connectionsPanelHeader .connectionsHeaderTitle { - text-transform: uppercase; letter-spacing: 1px; font-size: 16px; font-weight: lighter; } +.readerControls .connectionsPanelHeader .connectionsHeaderTitle:not(.active) { + text-transform: uppercase; +} .connectionsPanelHeader .connectionsHeaderTitle.active { - text-transform: none; cursor: pointer; } .connectionsHeaderTitle .fa-chevron-left { @@ -4965,7 +4979,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus } .readerControls.transLangPrefSuggBann { background-color: #EDEDEC; - z-index: 99; + z-index: 2; } .readerControls .readerControlsInner.transLangPrefSuggBannInner { justify-content: center; @@ -5055,7 +5069,6 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus } .readerControls.connectionsHeader .readerTextToc { font-family: "Roboto", "Helvetica Neue", "Helvetica", sans-serif; - text-transform: uppercase; color: #666; width: 100%; } @@ -5087,6 +5100,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus display: flex; flex-direction: row; text-align: right; + align-items: center; } /* icons need a little nudge in flipped hebrew mode */ .interface-hebrew .rightButtons { @@ -5504,7 +5518,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .bilingual .sheetContent .title .he { display: none; } -.interface-hebrew .readerPanel.english .textRange, +.interface-hebrew .readerPanel.ltr .textRange, .interface-hebrew .readerPanel.english .categoryFilterGroup, .interface-hebrew .readerPanel.bilingual .categoryFilterGroup, .interface-hebrew .readerPanel.english .essayGroup, @@ -5513,6 +5527,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .interface-hebrew .readerPanel.bilingual .textTableOfContents { direction: ltr; } +.interface-english .readerPanel.rtl .textRange, .interface-english .readerPanel.hebrew .textRange, .interface-english .readerPanel.hebrew .categoryFilterGroup, .interface-english .readerPanel.hebrew .essayGroup, @@ -5621,7 +5636,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus display: inline; }*/ .bilingual .segment > .he, -.bilingual .segment > p > .he{ +.bilingual .segment > p > .he { display: block; } .bilingual .segment > .en, @@ -5649,11 +5664,14 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .stacked.bilingual .sheetContent .segment > p > .en { margin-top: 0; } -.stacked.bilingual .basetext .segment > .en , -.stacked.bilingual .basetext .segment > p > .en { +.stacked.bilingual .basetext .segment > .translation , +.stacked.bilingual .basetext .segment > p > .translation { margin: 10px 0 20px; color: #666; } +.stacked.bilingual .basetext .segment > p > .he.translation { + color: black; +} .stacked.bilingual .segment.heOnly > .he, .stacked.bilingual .segment.enOnly > .en, .stacked.bilingual .segment.heOnly > p > .he, @@ -5708,20 +5726,28 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .readerPanel.english .SheetSource .sheetItem.segment .en { background-color: white; } -.heLeft.bilingual .segment > .en, -.heRight.bilingual .segment > .he , -.heLeft.bilingual .segment > p > .en, -.heRight.bilingual .segment > p > .he { +.heLeft.bilingual .segment > .translation, +.heRight.bilingual .segment > .primary, +.heLeft.bilingual .segment > p > .translation, +.heRight.bilingual .segment > p > .primary, +.heRight.bilingual .sheetItem.segment > .he, +.heLeft.bilingual .sheetItem.segment > .en{ float: right; padding-left: 20px; } -.heRight.bilingual .segment > .en, -.heLeft.bilingual .segment > .he, -.heRight.bilingual .segment > p > .en, -.heLeft.bilingual .segment > p > .he { +.heRight.bilingual .segment > .translation, +.heLeft.bilingual .segment > .primary, +.heRight.bilingual .segment > p > .translation, +.heLeft.bilingual .segment > p > .primary, +.heRight.bilingual .sheetItem.segment > .en, +.heLeft.bilingual .sheetItem.segment > .he{ float: left; padding-right: 20px; } +.segment > p > .he.translation { + --hebrew-font: var(--hebrew-sans-serif-font-family); + font-size: 100%; +} .basetext .segment:active, .basetext .segment:focus { background-color: #f5faff; @@ -5810,6 +5836,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .segment.heOnly .en{ display: none; } +/*in the text reader we don't have enOnly anymore. it always hvae primary (which is one the meaning of heOnly) maybe this is useful for other cases*/ .segment.enOnly .he{ display: none; } @@ -10626,11 +10653,14 @@ section.SheetOutsideBiText { .readerPanel.hebrew section.SheetSource .sheetItem > .he, .readerPanel.english section.SheetSource .sheetItem > .en, +.readerPanel.hebrew section.SheetSource .sheetItem.enOnly > .en > .sourceContentText, .readerPanel.hebrew section.SheetOutsideBiText .sheetItem > .he, .readerPanel.english section.SheetOutsideBiText .sheetItem > .en { display: block; } -.readerPanel.hebrew section.SheetSource .sheetItem > .en, +.readerPanel.hebrew section.SheetSource .sheetItem:not(.enOnly) > .en, +.readerPanel.hebrew section.SheetSource .sheetItem.enOnly > .en > .ref, +.readerPanel.hebrew section.SheetSource .sheetItem.enOnly > .he > .sourceContentText, .readerPanel.english section.SheetSource .sheetItem > .he, .readerPanel.hebrew section.SheetOutsideBiText .sheetItem > .en, .readerPanel.english section.SheetOutsideBiText .sheetItem > .he { @@ -13348,6 +13378,216 @@ span.ref-link-color-3 {color: blue} cursor: pointer; } +.dropdownMenu { + position: relative; + display: flex; + flex-direction: row-reverse; + z-index: 3; +} + +.texts-properties-menu { + width: 256px; + border: 1px solid var(--lighter-grey); + border-radius: 5px; + box-shadow: 0px 2px 4px var(--lighter-grey); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: absolute; + top: 100%; + background-color: white; + --english-font: var(--english-sans-serif-font-family); + --hebrew-font: var(--hebrew-sans-serif-font-family); +} + +.dropdownButton { + border: none; + background-color: inherit; +} + +.rightButtons .dropdownButton { + text-align: end; +} + +.toggle-switch-container { + align-items: center; + display: flex; + direction: ltr; +} + +.toggle-switch { + position: relative; + width: 46px; + display: inline-block; + text-align: left; +} + +.toggle-switch-checkbox { + display: none; +} + +.toggle-switch-label { + display: block; + overflow: hidden; + cursor: pointer; + border: 0 solid var(--light-grey); + border-radius: 20px; +} + +.toggle-switch-inner { + display: block; + width: 200%; + margin-left: -100%; + transition: margin 0.3s ease-in 0s; +} + +.toggle-switch-inner:before, +.toggle-switch-inner:after { + float: left; + width: 50%; + height: 24px; + padding: 0; + line-height: 24px; + color: white; + font-weight: bold; + box-sizing: border-box; + content: ""; + color: white; +} + +.toggle-switch-inner:before { + padding-left: 10px; + background-color: var(--sefaria-blue); +} + +.toggle-switch-inner:after { + padding-right: 10px; + background-color: var(--light-grey); +} + +.toggle-switch-switch { + display: block; + width: 20px; + height: 20px; + background: white; + position: absolute; + top: 50%; + bottom: 0; + right: 24px; + border: 0 solid var(--light-grey); + border-radius: 20px; + transition: all 0.3s ease-in 0s; + transform: translateY(-50%); +} + +.toggle-switch-checkbox:checked + .toggle-switch-label .toggle-switch-inner { + margin-left: 0; +} + +.toggle-switch-checkbox:checked + .toggle-switch-label .toggle-switch-switch { + right: 2px; +} + +.toggle-switch-checkbox:disabled + .toggle-switch-label .toggle-switch-inner:after { + background-color: var(--lighter-grey); +} + +.toggle-switch-line { + display: flex; + width: 216px; + height: 49px; + justify-content: space-between; + align-items: center; +} + +.toggle-switch-line:is(.disabled) .int-en, +.toggle-switch-line:is(.disabled) .int-he { + color: var(--light-grey); +} + +.texts-properties-menu .int-en, +.texts-properties-menu .int-he { + align-content: center; +} + +.show-source-translation-buttons { + display: flex; + flex-direction: column; + height: 170px; + justify-content: center; +} + +.show-source-translation-buttons .button { + margin: unset; + display: flex; + height: 46px; + width: 235px; + align-items: center; + justify-content: center; + margin: 3px 0; +} + +.show-source-translation-buttons .button:not(.checked) { + background-color: var(--lighter-grey); + color: black; +} + +.layout-button-line { + height: 57px; + width: 216px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.layout-options { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.layout-button { + border: none; + width: 28px; + height: 24px; + -webkit-mask: var(--url) no-repeat; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: 100% 100%; + background-color: var(--medium-grey); + cursor: pointer; +} + +.layout-button.checked { + background-color: var(--sefaria-blue); +} + +.text-menu-border { + width: 100%; + height: 1px; + background-color: var(--lighter-grey); +} + +.font-size-line { + width: 230px; + height: 50px; + display: flex; + justify-content: space-between; + align-items: center; + direction: ltr; +} + +.font-size-button { + display: flex; + align-items: center; + background-color: white; + border: none; + cursor: pointer; +} + #currentlyReadingContainer { margin: 5px 30px; flex-grow: 1; diff --git a/static/icons/bi-ltr-heLeft.svg b/static/icons/bi-ltr-heLeft.svg new file mode 100644 index 0000000000..2d28ee586f --- /dev/null +++ b/static/icons/bi-ltr-heLeft.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/bi-ltr-stacked.svg b/static/icons/bi-ltr-stacked.svg new file mode 100644 index 0000000000..27fb13f535 --- /dev/null +++ b/static/icons/bi-ltr-stacked.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/bi-rtl-heRight.svg b/static/icons/bi-rtl-heRight.svg new file mode 100644 index 0000000000..67a7c095d0 --- /dev/null +++ b/static/icons/bi-rtl-heRight.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/bi-rtl-stacked.svg b/static/icons/bi-rtl-stacked.svg new file mode 100644 index 0000000000..035734e83f --- /dev/null +++ b/static/icons/bi-rtl-stacked.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/enlarge_font.svg b/static/icons/enlarge_font.svg new file mode 100644 index 0000000000..3c1ff999d6 --- /dev/null +++ b/static/icons/enlarge_font.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/mixed-beside-ltrrtl.svg b/static/icons/mixed-beside-ltrrtl.svg new file mode 100644 index 0000000000..f19de475c1 --- /dev/null +++ b/static/icons/mixed-beside-ltrrtl.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/mixed-beside-rtlltr.svg b/static/icons/mixed-beside-rtlltr.svg new file mode 100644 index 0000000000..a35ebe96f5 --- /dev/null +++ b/static/icons/mixed-beside-rtlltr.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/mixed-stacked-ltrrtl.svg b/static/icons/mixed-stacked-ltrrtl.svg new file mode 100644 index 0000000000..a0d8d6bb75 --- /dev/null +++ b/static/icons/mixed-stacked-ltrrtl.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/mixed-stacked-rtlltr.svg b/static/icons/mixed-stacked-rtlltr.svg new file mode 100644 index 0000000000..3e088068f8 --- /dev/null +++ b/static/icons/mixed-stacked-rtlltr.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/mono-continuous.svg b/static/icons/mono-continuous.svg new file mode 100644 index 0000000000..f84c2f3fa8 --- /dev/null +++ b/static/icons/mono-continuous.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/mono-segmented.svg b/static/icons/mono-segmented.svg new file mode 100644 index 0000000000..9f1b6bffa9 --- /dev/null +++ b/static/icons/mono-segmented.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/icons/reduce_font.svg b/static/icons/reduce_font.svg new file mode 100644 index 0000000000..0714abe7fb --- /dev/null +++ b/static/icons/reduce_font.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/js/AboutBox.jsx b/static/js/AboutBox.jsx index 791b77e42c..8bf5e2b9bd 100644 --- a/static/js/AboutBox.jsx +++ b/static/js/AboutBox.jsx @@ -3,9 +3,10 @@ import PropTypes from 'prop-types'; import Sefaria from './sefaria/sefaria'; import VersionBlock, {VersionsBlocksList} from './VersionBlock/VersionBlock'; import Component from 'react-class'; -import {InterfaceText} from "./Misc"; +import {InterfaceText, LoadingMessage} from "./Misc"; import {ContentText} from "./ContentText"; import { Modules } from './NavSidebar'; +import {VersionsTextList} from "./VersionsTextList"; class AboutBox extends Component { @@ -63,8 +64,8 @@ class AboutBox extends Component { this.setState({versionLangMap: versionsByLang, currentVersionsByActualLangs:currentVersionsByActualLangs}); } openVersionInSidebar(versionTitle, versionLanguage) { - this.props.setConnectionsMode("Translation Open", {previousMode: "About"}); - this.props.setFilter(Sefaria.getTranslateVersionsKey(versionTitle, versionLanguage)); + this.props.setConnectionsMode("Version Open", {previousMode: "About"}); + this.props.setFilter(Sefaria.getTranslateVersionsKey(versionTitle, versionLanguage), 'About'); } isSheet(){ return this.props.srefs[0].startsWith("Sheet"); @@ -94,14 +95,36 @@ class AboutBox extends Component { return
{detailSection}
; } + if (!Object.keys(this.state.versionLangMap).length) { + return ( +
+ +
+ ); + } + + if (this.props.mode === "Version Open") { + return ( + + ); + } + const category = Sefaria.index(this.state?.details?.title)?.primary_category; const isDictionary = d?.lexiconName; - const sourceVersion = this.state.currentVersionsByActualLangs?.he; - const translationVersions = Object.entries(this.state.currentVersionsByActualLangs).filter(([lang, version]) => lang != "he").map(([lang, version])=> version); - const multiple_translations = translationVersions?.length > 1; - const no_source_versions = multiple_translations || translationVersions?.length == 1 && !sourceVersion; + const sourceVersion = this.props.currObjectVersions.he; + const translationVersion = this.props.currObjectVersions?.en; + const no_source_versions = !sourceVersion; const sourceVersionSectionTitle = {en: "Current Version", he:"מהדורה נוכחית"}; - const translationVersionsSectionTitle = multiple_translations ? {en: "Current Translations", he:"תרגומים נוכחיים"} : {en: "Current Translation", he:"תרגום נוכחי"}; + const translationVersionSectionTitle = {en: "Current Translation", he:"תרגום נוכחי"}; const alternateVersionsSectionTitle = no_source_versions ? {en: "Source Versions", he:"מהדורות בשפת המקור"} : {en: "Alternate Source Versions", he:"מהדורות נוספות בשפת המקור"} let detailSection = null; @@ -163,8 +186,10 @@ class AboutBox extends Component { { !!placeTextEn || !!dateTextEn ?
- {`Composed: ${!!placeTextEn ? placeTextEn : ""} ${!!dateTextEn ? dateTextEn : ""}`} - {`נוצר/נערך: ${!!placeTextHe ? placeTextHe : ""} ${!!dateTextHe ? dateTextHe : ""}`} +
: null } @@ -188,25 +213,21 @@ class AboutBox extends Component { : null ); const versionSectionEn = - (!!translationVersions?.length ? + (!!translationVersion?.versionTitle ?

- +

- { - translationVersions.map((ve) => ( - - )) - } +
: null ); const alternateSectionHe = (Object.values(this.state.versionLangMap).some(array => array?.length) ? @@ -243,6 +264,9 @@ AboutBox.propTypes = { masterPanelLanguage: PropTypes.oneOf(["english", "hebrew", "bilingual"]), title: PropTypes.string.isRequired, srefs: PropTypes.array.isRequired, + vFilter: PropTypes.array, + onRangeClick: PropTypes.func, + onCitationClick: PropTypes.func, }; diff --git a/static/js/AddToSourceSheet.jsx b/static/js/AddToSourceSheet.jsx index 3b456e1a11..a2cb3f3f5c 100644 --- a/static/js/AddToSourceSheet.jsx +++ b/static/js/AddToSourceSheet.jsx @@ -117,71 +117,103 @@ class AddToSourceSheetBox extends Component { normalize(text){ return(text.replaceAll(/()+/g, ' ').replace(/\u2009/g, ' ').replace(/<[^>]*>/g, '')); } - async addToSourceSheet() { - if (!Sefaria._uid) { - this.props.toggleSignUpModal(SignUpModalKind.AddToSheet); - } - if (!this.state.selectedSheet || !this.state.selectedSheet.id) { return; } + + async postToSheet(source) { + if (this.checkContentForImages(source.refs)) { const url = "/api/sheets/" + this.state.selectedSheet.id + "/add"; - const language = this.props.contentLanguage; - let source = {}; - if(this.props.en || this.props.he){ // legacy code to support a call to this component in Gardens. - if(this.props.srefs){ //we are saving a ref + ref's text, generally all fields should be present. - source.refs = this.props.srefs; - source.en = this.props.en; - source.he = this.props.he; - }else{ // an outside free text is being passed in. theoretically supports any interface that passes this in. In practice only legacy Gardens code. - if (this.props.en && this.props.he) { - source.outsideBiText = {he: this.props.he, en: this.props.en}; - } else { - source.outsideText = this.props.en || this.props.he; - } - } - } 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; + let postData = {source: JSON.stringify(source)}; + if (this.props.note) { + postData.note = this.props.note; + } + await $.post(url, postData, this.confirmAdd); + } + } + makeSourceForEden() { + if (this.props.srefs) { //we are saving a ref + ref's text, generally all fields should be present. + source.refs = this.props.srefs; + source.en = this.props.en; + source.he = this.props.he; + } else { // an outside free text is being passed in. theoretically supports any interface that passes this in. In practice only legacy Gardens code. + if (this.props.en && this.props.he) { + source.outsideBiText = {he: this.props.he, en: this.props.en}; + } else { + source.outsideText = this.props.en || this.props.he; + } + } + } + async handleSelectedWords(source, lan) { + // If something is highlighted and main panel language is not bilingual: + // Use passed in language to determine which version this highlight covers. + let selectedWords = this.props.selectedWords; //if there was highlighted single panel + const language = this.props.contentLanguage; + if (!selectedWords || language === "bilingual") { + return; + } + 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); + } + + async handleSameDirectionVersions() { + for (const lang of ['he', 'en']) { + const version = this.props.currObjectVersions[lang]; + const source = { + refs: this.props.srefs, + [`version-${version.language}`]: version.versionTitle + } + await this.postToSheet(source); + } + } - 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; } + async addToSourceSheet() { + if (!Sefaria._uid) { + this.props.toggleSignUpModal(SignUpModalKind.AddToSheet); + } + if (!this.state.selectedSheet || !this.state.selectedSheet.id) { + return; + } - // If something is highlighted and main panel language is not bilingual: - // Use passed in language to determine which version this highlight covers. - let selectedWords = this.props.selectedWords; //if there was highlighted single panel - if (selectedWords && language != "bilingual") { - 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; - } - } + const source = {}; + let en, he; + if (this.props.en || this.props.he) { // legacy code to support a call to this component in Gardens. + this.makeSourceForEden(); + } 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; - source[lan] = sheetsUtils.segmentsToSourceText(segments, lan); - } - } - if (this.checkContentForImages(source.refs)) { - let postData = {source: JSON.stringify(source)}; - if (this.props.note) { - postData.note = this.props.note; - } - $.post(url, postData, this.confirmAdd); + ({ en, he } = this.props.currObjectVersions || {"en": null, "he": null}); //the text we are adding may be non-default version + if (en?.direction && en?.direction === he?.direction) { + await this.handleSameDirectionVersions(); + return; + } else if (en?.direction === 'rtl' || he?.direction === 'ltr') { + ([en, he] = [he, en]); } + + if (he) { source["version-he"] = he.versionTitle; } + if (en) { source["version-en"] = en.versionTitle; } + } + const contentLang = he?.language || en?.language; // this matters only if one language is shown. + await this.handleSelectedWords(source, contentLang); + await this.postToSheet(source); } checkContentForImages(refs) { // validate texts corresponding to refs have no images before posting them to sheet diff --git a/static/js/BookPage.jsx b/static/js/BookPage.jsx index 4c87581a41..a348317556 100644 --- a/static/js/BookPage.jsx +++ b/static/js/BookPage.jsx @@ -27,10 +27,12 @@ import Footer from './Footer'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import Component from 'react-class'; -import {ContentLanguageContext} from './context'; +import {ReaderPanelContext} from './context'; import Hebrew from './sefaria/hebrew.js'; import ReactTags from 'react-tag-autocomplete'; +import ReaderDisplayOptionsMenu from "./ReaderDisplayOptionsMenu"; +import DropdownMenu from "./common/DropdownMenu"; import Cookies from "js-cookie"; @@ -68,7 +70,7 @@ class BookPage extends Component { } getData() { // Gets data about this text from cache, which may be null. - return Sefaria.text(this.getDataRef(), {context: 1, enVersion: this.props.currVersions.en, heVersion: this.props.currVersions.he}); + return Sefaria.text(this.getDataRef(), {context: 1, enVersion: this.props.currVersions.en?.versionTitle, heVersion: this.props.currVersions.he?.versionTitle}); } loadData() { // Ensures data this text is in cache, rerenders after data load if needed @@ -90,7 +92,7 @@ class BookPage extends Component { let currObjectVersions = {en: null, he: null}; for(let [lang,ver] of Object.entries(this.props.currVersions)){ if(!!ver){ - let fullVer = versions.find(version => version.versionTitle == ver && version.language == lang); + let fullVer = versions.find(version => version.versionTitle == ver.versionTitle && version.language == lang); currObjectVersions[lang] = fullVer ? fullVer : null; } } @@ -132,18 +134,15 @@ class BookPage extends Component { currentVersion.merged = !!(currentVersion.sources); return currentVersion; } - openVersion(version, language) { + openVersion(version, language, versionLanguageFamily) { // Selects a version and closes this menu to show it. // Calling this functon wihtout parameters resets to default - this.props.selectVersion(version, language); + this.props.selectVersion(version, language, versionLanguageFamily); this.props.close(); } isBookToc() { return (this.props.mode == "book toc") } - isTextToc() { - return (this.props.mode == "text toc") - } extendedNotesBack(event){ return null; } @@ -169,7 +168,7 @@ class BookPage extends Component { catUrl = "/texts/" + category; } - const readButton = !this.state.indexDetails || this.isTextToc() || this.props.compare ? null : + const readButton = !this.state.indexDetails || this.props.compare ? null : Sefaria.lastPlaceForText(title) ? Continue Reading @@ -210,28 +209,21 @@ class BookPage extends Component { return (
- {this.isTextToc() || this.props.compare ? + {this.props.compare ? <>
- {this.props.compare ? - : }
- {this.props.compare ?
{title}
- : -
- Table of Contents -
}
{Sefaria.interfaceLang !== "hebrew" ? - + )} menu={()} context={ReaderPanelContext}/> : }
diff --git a/static/js/ComparePanelHeader.jsx b/static/js/ComparePanelHeader.jsx index 0de8a1ed3c..ad2070da59 100644 --- a/static/js/ComparePanelHeader.jsx +++ b/static/js/ComparePanelHeader.jsx @@ -9,8 +9,11 @@ import { SearchButton, } from './Misc'; import {ContentText} from "./ContentText"; +import DropdownMenu from "./common/DropdownMenu"; +import ReaderDisplayOptionsMenu from "./ReaderDisplayOptionsMenu"; +import {ReaderPanelContext} from "./context"; -const ComparePanelHeader = ({ search, category, openDisplaySettings, navHome, catTitle, heCatTitle, +const ComparePanelHeader = ({ search, category, openDisplaySettings, navHome, catTitle, heCatTitle, onBack, openSearch }) => { if (search) { @@ -34,7 +37,7 @@ const ComparePanelHeader = ({ search, category, openDisplaySettings, navHome, ca
{Sefaria.interfaceLang !== "hebrew" ? - + )} menu={()} context={ReaderPanelContext}/> : null} ); @@ -47,9 +50,9 @@ const ComparePanelHeader = ({ search, category, openDisplaySettings, navHome, ca - {(Sefaria.interfaceLang === "hebrew" || !openDisplaySettings) ? + {(Sefaria.interfaceLang === "hebrew") ? - : } + : )} menu={()} context={ReaderPanelContext}/>} ); } diff --git a/static/js/ConnectionsPanel.jsx b/static/js/ConnectionsPanel.jsx index b874731285..53407a14e4 100644 --- a/static/js/ConnectionsPanel.jsx +++ b/static/js/ConnectionsPanel.jsx @@ -18,7 +18,8 @@ import { } from './Media'; import { CategoryFilter, TextFilter } from './ConnectionFilters'; -import React, { useRef, useState, useEffect } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; +import { ReaderPanelContext } from './context'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import Sefaria from './sefaria/sefaria'; @@ -50,7 +51,7 @@ class ConnectionsPanel extends Component { this.state = { flashMessage: null, currObjectVersions: { en: null, he: null }, - mainVersionLanguage: props.masterPanelLanguage === "bilingual" ? "hebrew" : props.masterPanelLanguage, + // mainVersionLanguage: props.masterPanelLanguage === "bilingual" ? "hebrew" : props.masterPanelLanguage, availableTranslations: [], linksLoaded: false, // has the list of refs been loaded connectionSummaryCollapsed: true, @@ -63,7 +64,7 @@ class ConnectionsPanel extends Component { componentDidMount() { this._isMounted = true; this.loadData(); - this.getCurrentVersions(); + this.setCurrentVersions(); this.debouncedCheckVisibleSegments = Sefaria.util.debounce(this.checkVisibleSegments, 100); this.addScrollListener(); } @@ -73,7 +74,7 @@ class ConnectionsPanel extends Component { this.removeScrollListener(); } componentDidUpdate(prevProps, prevState) { - if (!prevProps.srefs.compare(this.props.srefs)) { + if (prevProps.srefs !== this.props.srefs) { this.loadData(); } // Turn on the lexicon when receiving new words if they are less than 3 @@ -90,11 +91,10 @@ class ConnectionsPanel extends Component { this.props.setConnectionsMode("Resources"); } - if (prevProps.currVersions.en !== this.props.currVersions.en || - prevProps.currVersions.he !== this.props.currVersions.he || + if (!Sefaria.areBothVersionsEqual(prevProps.currVersions, this.props.currVersions) || prevProps.masterPanelLanguage !== this.props.masterPanelLanguage || prevProps.srefs[0] !== this.props.srefs[0]) { - this.getCurrentVersions(); + this.setCurrentVersions(); } if (prevProps.mode !== this.props.mode || prevProps.connectionsCategory !== this.props.connectionsCategory) { @@ -188,7 +188,7 @@ class ConnectionsPanel extends Component { return Sefaria.sectionRef(Sefaria.humanRef(this.props.srefs), true) || this.props.srefs; } loadData() { - let ref = this.sectionRef(); + let ref = this.props.srefs[0]; if (!Sefaria.related(ref)) { Sefaria.related(ref, function (data) { if (this._isMounted) { @@ -232,51 +232,29 @@ class ConnectionsPanel extends Component { this.props.setConnectionsMode("Resources"); this.flashMessage("Success! You've created a new connection."); } - getData(cb) { + async getData() { // Gets data about this text from cache, which may be null. const versionPref = Sefaria.versionPreferences.getVersionPref(this.props.srefs[0]); - return Sefaria.getText(this.props.srefs[0], { context: 1, enVersion: this.props.currVersions.en, heVersion: this.props.currVersions.he, translationLanguagePreference: this.props.translationLanguagePreference, versionPref}).then(cb); - } - getVersionFromData(d, lang) { - //d - data received from this.getData() - //language - the language of the version - //console.log(d); - const currentVersionTitle = (lang === "he") ? d.heVersionTitle : d.versionTitle; - return { - ...d.versions.find(v => v.versionTitle === currentVersionTitle && v.language === lang), - title: d.indexTitle, - heTitle: d.heIndexTitle, - sources: lang === "he" ? d.heSources : d.sources, - merged: lang === "he" ? !!d.heSources : !!d.sources, - } + return await Sefaria.getTextFromCurrVersions(this.props.srefs[0], this.props.currVersions, this.props.translationLanguagePreference, 1); } - getCurrentVersions() { - const data = this.getData((data) => { - let currentLanguage = this.props.masterPanelLanguage; - if (currentLanguage === "bilingual") { - currentLanguage = "hebrew" - } - if (!data || data.error) { - this.setState({ - currObjectVersions: { en: null, he: null }, - mainVersionLanguage: currentLanguage, - }); - return - } - if (currentLanguage === "hebrew" && !data.he.length) { - currentLanguage = "english" - } - if (currentLanguage === "english" && !data.text.length) { - currentLanguage = "hebrew" - } + async setCurrentVersions() { + const data = await this.getData(); + let currentLanguage = this.props.masterPanelLanguage; + if (currentLanguage === "bilingual") { + currentLanguage = "hebrew" + } + if (!data || data.error) { this.setState({ - currObjectVersions: { - en: ((this.props.masterPanelLanguage !== "hebrew" && !!data.text.length) || (this.props.masterPanelLanguage === "hebrew" && !data.he.length)) ? this.getVersionFromData(data, "en") : null, - he: ((this.props.masterPanelLanguage !== "english" && !!data.he.length) || (this.props.masterPanelLanguage === "english" && !data.text.length)) ? this.getVersionFromData(data, "he") : null, - }, - mainVersionLanguage: currentLanguage, - sectionRef: data.sectionRef, + currObjectVersions: { en: null, he: null }, }); + } + const [primary, translation] = Sefaria.getPrimaryAndTranslationFromVersions(data.versions); + this.setState({ + currObjectVersions: { + en: ((this.props.masterPanelLanguage !== "hebrew" && !!data.text.length) || (this.props.masterPanelLanguage === "hebrew" && !data.he.length)) ? translation : null, + he: ((this.props.masterPanelLanguage !== "english" && !!data.he.length) || (this.props.masterPanelLanguage === "english" && !data.text.length)) ? primary : null, + }, + sectionRef: data.sectionRef, }); } checkSrefs(srefs) { @@ -449,7 +427,6 @@ class ConnectionsPanel extends Component { onCitationClick={this.props.onCitationClick} handleSheetClick={this.props.handleSheetClick} openNav={this.props.openNav} - openDisplaySettings={this.props.openDisplaySettings} closePanel={this.props.closePanel} selectedWords={this.props.selectedWords} checkVisibleSegments={this.checkVisibleSegments} @@ -487,14 +464,14 @@ class ConnectionsPanel extends Component { selectedWordsForSheet = null; } else { // add source from sheet itself refForSheet = this.props.srefs; - versionsForSheet = this.props.currVersions; + versionsForSheet = this.state.currObjectVersions; selectedWordsForSheet = this.props.selectedWords; nodeRef = this.props.nodeRef; } content = (
); - } else if (this.props.mode === "About") { + } else if (this.props.mode === "About" || this.props.mode === 'Version Open') { content = ( { - const editText = canEditText ? function () { + const {textsData} = useContext(ReaderPanelContext); + const editText = canEditText && textsData ? function () { + const {primaryLang, translationLang} = textsData; let refString = srefs[0]; let currentPath = Sefaria.util.currentPath(); - let currentLangParam; - const langCode = masterPanelLanguage.slice(0, 2); - if (currVersions[langCode]) { - refString += "/" + encodeURIComponent(langCode) + "/" + encodeURIComponent(currVersions[langCode]); + const language = (masterPanelLanguage === 'english') ? translationLang : primaryLang; + const langCode = language.slice(0, 2); + const currVersionsLangCode = masterPanelLanguage.slice(0, 2); + const {versionTitle} = currVersions[currVersionsLangCode]; + if (versionTitle) { + refString += "/" + encodeURIComponent(langCode) + "/" + encodeURIComponent(versionTitle); } let path = "/edit/" + refString; let nextParam = "?next=" + encodeURIComponent(currentPath); diff --git a/static/js/ConnectionsPanelHeader.jsx b/static/js/ConnectionsPanelHeader.jsx index 9172a818a9..3b2cd92959 100644 --- a/static/js/ConnectionsPanelHeader.jsx +++ b/static/js/ConnectionsPanelHeader.jsx @@ -1,4 +1,4 @@ -import {InterfaceText, EnglishText, HebrewText, LanguageToggleButton, CloseButton } from "./Misc"; +import {InterfaceText, EnglishText, HebrewText, LanguageToggleButton, CloseButton, DisplaySettingsButton} from "./Misc"; import {RecentFilterSet} from "./ConnectionFilters"; import React from 'react'; import ReactDOM from 'react-dom'; @@ -7,6 +7,9 @@ import Sefaria from './sefaria/sefaria'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import Component from 'react-class'; +import {ReaderPanelContext} from "./context"; +import DropdownMenu from "./common/DropdownMenu"; +import ReaderDisplayOptionsMenu from "./ReaderDisplayOptionsMenu"; class ConnectionsPanelHeader extends Component { @@ -114,12 +117,14 @@ class ConnectionsPanelHeader extends Component { const toggleLang = Sefaria.util.getUrlVars()["lang2"] === "en" ? "he" : "en"; const langUrl = Sefaria.util.replaceUrlParam("lang2", toggleLang); const closeUrl = Sefaria.util.removeUrlParam("with"); + const showOneLanguage = !Sefaria._siteSettings.TORAH_SPECIFIC || Sefaria.interfaceLang === "hebrew"; + const toggleButton = (showOneLanguage) ? null : (this.props.connectionsMode === 'TextList') ? + } menu={} context={ReaderPanelContext}/> : + ; return (
{title}
- {Sefaria.interfaceLang !== "hebrew" && Sefaria._siteSettings.TORAH_SPECIFIC ? - - : null } + {toggleButton}
); diff --git a/static/js/ContentText.jsx b/static/js/ContentText.jsx index 2e0c14049d..41346075ad 100644 --- a/static/js/ContentText.jsx +++ b/static/js/ContentText.jsx @@ -2,6 +2,7 @@ import React from "react"; import {useContentLang} from './Hooks'; import Sefaria from './sefaria/sefaria'; import ReactMarkdown from "react-markdown"; +import PropTypes from "prop-types"; const ContentText = (props) => { /* Renders content language throughout the site (content that comes from the database and is not interface language). @@ -18,24 +19,28 @@ const ContentText = (props) => { return langAndContentItems.map(item => ); }; -const VersionContent = (props) => { - /* Used to render content of Versions. - * imageLoadCallback is called to update segment numbers placement - * overrideLanguage a string with the language name (full not 2 letter) to force to render to overriding what the content language context says. Can be useful if calling object determines one langugae is missing in a dynamic way - * defaultToInterfaceOnBilingual use if you want components not to render all languages in bilingual mode, and default them to what the interface language is - * See filterContentTextByLang for more documentation */ - const langAndContentItems = _filterContentTextByLang(props); - const [languageToFilter, _] = useContentLang(props.defaultToInterfaceOnBilingual, props.overrideLanguage); - return langAndContentItems.map((item) => { - const [lang, content] = item; - if (Sefaria.isFullSegmentImage(content)){ - return(); - } - return (); - }) +const VersionContent = ({primary, translation, imageLoadCallback}) => { + /** + * Used to render content of Versions. + * imageLoadCallback is called to update segment numbers placement + */ + const versions = {primary, translation}; + return Object.keys(versions).map((key) => { + const version = versions[key]; + const lang = (version.direction === 'rtl') ? 'he' : 'en'; + const toFilter = key === 'primary' && !!primary && !!translation; + return (Sefaria.isFullSegmentImage(version.text)) ? + () : + (); + }); +} +VersionContent.propTypes = { + primary: PropTypes.object, + translation: PropTypes.object, + imageLoadCallback: PropTypes.func, } -const VersionImageSpan = ({lang, content, languageToFilter, imageLoadCallback}) => { +const VersionImageSpan = ({lang, content, toFilter, imageLoadCallback}) => { function getImageAttribute(imgTag, attribute) { const parser = new DOMParser(); const doc = parser.parseFromString(imgTag, 'text/html'); @@ -50,9 +55,8 @@ const VersionImageSpan = ({lang, content, languageToFilter, imageLoadCallback}) const altText = getImageAttribute(content, 'alt'); const srcText = getImageAttribute(content, 'src'); content = (
{{altText}/}

{altText}

); - if (lang === 'he' && languageToFilter === "bilingual") {content = ''} - - return({content}) + if (toFilter) {content = ''} + return ({content}) }; const _filterContentTextByLang = ({text, html, markdown, overrideLanguage, defaultToInterfaceOnBilingual=false, bilingualOrder = null}) => { @@ -77,9 +81,9 @@ const _filterContentTextByLang = ({text, html, markdown, overrideLanguage, defau return langAndContentItems; } -const ContentSpan = ({lang, content, isHTML, markdown}) => { +const ContentSpan = ({lang, content, isHTML, markdown, primaryOrTranslation}) => { return isHTML ? - + : markdown ? {content} diff --git a/static/js/ExtendedNotes.jsx b/static/js/ExtendedNotes.jsx index 739385d047..c2b5ba9752 100644 --- a/static/js/ExtendedNotes.jsx +++ b/static/js/ExtendedNotes.jsx @@ -13,7 +13,7 @@ class ExtendedNotes extends Component { this.state = {'notesLanguage': Sefaria.interfaceLang, 'extendedNotes': '', 'langToggle': false}; } getVersionData(versionList){ - const versionTitle = this.props.currVersions['en'] ? this.props.currVersions['en'] : this.props.currVersions['he']; + const versionTitle = this.props.currVersions['en'] ? this.props.currVersions['en'].versionTitle : this.props.currVersions['he'].versionTitle; const thisVersion = versionList.filter(x=>x.versionTitle===versionTitle)[0]; let extendedNotes = {'english': thisVersion.extendedNotes, 'hebrew': thisVersion.extendedNotesHebrew}; diff --git a/static/js/FontSizeButton.jsx b/static/js/FontSizeButton.jsx new file mode 100644 index 0000000000..0fdef3f3e7 --- /dev/null +++ b/static/js/FontSizeButton.jsx @@ -0,0 +1,20 @@ +import React, {useContext} from "react"; +import {InterfaceText} from "./Misc"; +import PropTypes from "prop-types"; +import {ReaderPanelContext} from "./context"; + +function FontSizeButtons() { + const {setOption} = useContext(ReaderPanelContext); + return ( +
+ + Font Size + +
+ ); +} +export default FontSizeButtons; diff --git a/static/js/Hooks.jsx b/static/js/Hooks.jsx index 115812ee73..bfed1913da 100644 --- a/static/js/Hooks.jsx +++ b/static/js/Hooks.jsx @@ -1,6 +1,6 @@ import React, {useState, useEffect, useMemo, useCallback, useRef, useContext} from 'react'; import $ from './sefaria/sefariaJquery'; -import {ContentLanguageContext} from "./context"; +import {ReaderPanelContext} from "./context"; import Sefaria from "./sefaria/sefaria"; @@ -8,8 +8,20 @@ function useContentLang(defaultToInterfaceOnBilingual, overrideLanguage){ /* useful for determining language for content text while taking into account ContentLanguageContent and interfaceLang * `overrideLanguage` a string with the language name (full not 2 letter) to force to render to overriding what the content language context says. Can be useful if calling object determines one langugae is missing in a dynamic way * `defaultToInterfaceOnBilingual` use if you want components not to render all languages in bilingual mode, and default them to what the interface language is*/ - const contentLanguage = useContext(ContentLanguageContext); - const languageToFilter = (defaultToInterfaceOnBilingual && contentLanguage.language === "bilingual") ? Sefaria.interfaceLang : (overrideLanguage ? overrideLanguage : contentLanguage.language); + const {language, textsData} = useContext(ReaderPanelContext); + const hasContent = !!textsData; + const shownLanguage = (language === "bilingual") ? language : (language === "english" && textsData?.text?.length) ? textsData?.translationLang : textsData?.primaryLang; //the 'hebrew' of language means source + const isContentLangAmbiguous = !['hebrew', 'english'].includes(shownLanguage); + let languageToFilter; + if (defaultToInterfaceOnBilingual && hasContent && isContentLangAmbiguous) { + languageToFilter = Sefaria.interfaceLang; + } else if (overrideLanguage) { + languageToFilter = overrideLanguage; + } else if (isContentLangAmbiguous || !hasContent) { + languageToFilter = language; + } else { + languageToFilter = shownLanguage; + } const langShort = languageToFilter.slice(0,2); return [languageToFilter, langShort]; } @@ -261,6 +273,27 @@ function usePaginatedLoad(fetchDataByPage, setter, identityElement, numPages, re }, [fetchPage]); } +function useOutsideClick(ref, onClickOutside, isActive=true) { + useEffect(() => { + /** + * Executes onClickOutside if clicked on outside of element + */ + function handleClickOutside(event) { + if (ref.current && !ref.current.contains(event.target)) { + onClickOutside(); + } + } + if (isActive) { + // Bind the event listener + document.addEventListener("mouseup", handleClickOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener("mouseup", handleClickOutside); + }; + } + }, [ref, isActive]); +} + export { useScrollToLoad, @@ -269,4 +302,5 @@ export { useDebounce, useContentLang, useIncrementalLoad, + useOutsideClick, }; diff --git a/static/js/LayoutButtons.jsx b/static/js/LayoutButtons.jsx new file mode 100644 index 0000000000..c77be9d876 --- /dev/null +++ b/static/js/LayoutButtons.jsx @@ -0,0 +1,56 @@ +import {useContext} from "react"; +import {ReaderPanelContext} from "./context"; +import {layoutOptions} from "./constants"; +import {InterfaceText} from "./Misc"; + +const calculateLayoutState = () => { + const {language, textsData, panelMode} = useContext(ReaderPanelContext); + const primaryDir = textsData?.primaryDirection; + const translationDir = textsData?.translationDirection; + return (language !== 'bilingual') ? 'mono' //one text + : (primaryDir !== translationDir || panelMode === 'Sheet') ? 'mixed' //two texts with different directions + : (primaryDir === 'rtl') ? 'bi-rtl' //two rtl texts + : 'bi-ltr'; //two ltr texts +} + +const LayoutButtons = () => { + const {language, textsData, setOption, layout, panelMode} = useContext(ReaderPanelContext); + const layoutState = calculateLayoutState(); + + const getPath = (layoutOption) => { + if (layoutState === 'mixed') { + const primaryDirection = textsData?.primaryDirection || 'rtl'; //no primary is the case of sheet + const translationDirection = textsData?.translationDirection || primaryDirection.split('').reverse().join(''); //when there is an empty translation it has no direction. we will show the button as opposite layouts. + const directions = (layoutOption === 'heLeft') ? `${primaryDirection}${translationDirection}` //heLeft means primary in left + : `${translationDirection}${primaryDirection}`; + if (layoutOption !== 'stacked') { + layoutOption = 'beside'; + } + layoutOption = `${layoutOption}-${directions}`; + } + return `/static/icons/${layoutState}-${layoutOption}.svg`; + } + const layoutButton = (layoutOption) => { + const path = getPath(layoutOption) + const optionName = (language === 'bilingual') ? 'biLayout' : 'layout'; + return ( +
+ {isMenuOpen && menu} +
+ ); +}; +DropdownMenu.propTypes = { + buttonContent: PropTypes.elementType.isRequired, + menu: PropTypes.elementType.isRequired, +}; +export default DropdownMenu; diff --git a/static/js/common/ToggleSwitch.jsx b/static/js/common/ToggleSwitch.jsx new file mode 100644 index 0000000000..3b303472f8 --- /dev/null +++ b/static/js/common/ToggleSwitch.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import PropTypes from "prop-types"; + +function ToggleSwitch({name, disabled, onChange, isChecked}) { + return ( +
+
+ + +
+
+ ); +} +ToggleSwitch.propTypes = { + name: PropTypes.string, + disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired, + isChecked: PropTypes.bool.isRequired, +}; +export default ToggleSwitch; diff --git a/static/js/common/ToggleSwitchLine.jsx b/static/js/common/ToggleSwitchLine.jsx new file mode 100644 index 0000000000..1e670e1a0e --- /dev/null +++ b/static/js/common/ToggleSwitchLine.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import PropTypes from "prop-types"; +import ToggleSwitch from "./ToggleSwitch"; +import {InterfaceText} from "../Misc"; + +function ToggleSwitchLine({name, onChange, isChecked, text, disabled=false}) { + return ( +
+ {text} + +
+ ); +} +ToggleSwitchLine.propTypes = { + name: PropTypes.string, + disabled: PropTypes.bool, + text: PropTypes.string, + onChange: PropTypes.func.isRequired, + isChecked: PropTypes.bool.isRequired, +}; +export default ToggleSwitchLine; diff --git a/static/js/constants.js b/static/js/constants.js new file mode 100644 index 0000000000..a55f2ad492 --- /dev/null +++ b/static/js/constants.js @@ -0,0 +1,6 @@ +export const layoutOptions = { + 'mono': ['continuous', 'segmented'], + 'bi-rtl': ['stacked', 'heRight'], + 'bi-ltr': ['stacked', 'heLeft'], + 'mixed': ['stacked', 'heLeft', 'heRight'], +}; diff --git a/static/js/context.js b/static/js/context.js index 129b99a4be..3939d6b457 100644 --- a/static/js/context.js +++ b/static/js/context.js @@ -1,9 +1,9 @@ import React, { useContext, useEffect, useState } from "react"; -const ContentLanguageContext = React.createContext({ +const ReaderPanelContext = React.createContext({ language: "english", }); -ContentLanguageContext.displayName = "ContentLanguageContext"; //This lets us see this name in the devtools +ReaderPanelContext.displayName = "ContentLanguageContext"; //This lets us see this name in the devtools const AdContext = React.createContext({}); AdContext.displayName = "AdContext"; @@ -330,7 +330,7 @@ function StrapiDataProvider({ children }) { } export { - ContentLanguageContext, + ReaderPanelContext, AdContext, StrapiDataProvider, StrapiDataContext, diff --git a/static/js/sefaria/api.js b/static/js/sefaria/api.js new file mode 100644 index 0000000000..2ba8834328 --- /dev/null +++ b/static/js/sefaria/api.js @@ -0,0 +1,15 @@ +// export default async function read(url, headers = {}) { +// const response = await fetch(url, { +// method: 'GET', +// headers: headers, +// credentials: 'include', +// }); +// return response.json(); +// } + +import Sefaria from "./sefaria"; + +export default async function read(url) { + const r = await Sefaria._ApiPromise(url); + return r; +} \ No newline at end of file diff --git a/static/js/sefaria/sefaria.js b/static/js/sefaria/sefaria.js index a33e3d3f65..4c587fdf67 100644 --- a/static/js/sefaria/sefaria.js +++ b/static/js/sefaria/sefaria.js @@ -461,7 +461,7 @@ Sefaria = extend(Sefaria, { //swap out original versions from the server with the ones that Sefaria client side has sorted and updated with some fields. // This happens before saving the text to cache so that both caches are consistent if(d?.versions?.length){ - let versions = Sefaria._saveVersions(d.sectionRef, d.versions); + let versions = Sefaria.saveVersions(d.sectionRef, d.versions); d.versions = Sefaria._makeVersions(versions, false); } Sefaria._saveText(d, settings); @@ -540,29 +540,124 @@ Sefaria = extend(Sefaria, { return language; } }, - makeUrlForAPIV3Text: function(ref, requiredVersions, mergeText) { + makeUrlForAPIV3Text: function(ref, requiredVersions, mergeText, return_format) { const host = Sefaria.apiHost; const endPoint = '/api/v3/texts/'; const versions = requiredVersions.map(obj => - Sefaria.makeParamsStringForAPIV3(obj.language, obj.versionTitle) + Sefaria.makeParamsStringForAPIV3(obj.languageFamilyName, obj.versionTitle) ); const mergeTextInt = mergeText ? 1 : 0; - const url = `${host}${endPoint}${ref}?version=${versions.join('&version=')}&fill_in_missing_segments=${mergeTextInt}`; + const return_format_string = (return_format) ? `&return_format=${return_format}` : ''; + const url = `${host}${endPoint}${ref}?version=${versions.join('&version=')}&fill_in_missing_segments=${mergeTextInt}${return_format_string}`; return url; }, - getTextsFromAPIV3: async function(ref, requiredVersions, mergeText) { + _textsStore: {}, + getTextsFromAPIV3: async function(ref, requiredVersions, mergeText, return_format) { // ref is segment ref or bottom level section ref - // requiredVersions is array of objects that can have language and versionTitle - const url = Sefaria.makeUrlForAPIV3Text(ref, requiredVersions, mergeText); - //TODO here's the place for getting it from cache - const apiObject = await Sefaria._ApiPromise(url); - //TODO here's the place for all changes we want to add, and saving in cache + // requiredVersions is array of objects that can have languageFamilyName and versionTitle + const url = Sefaria.makeUrlForAPIV3Text(ref, requiredVersions, mergeText, return_format); + const apiObject = await Sefaria._cachedApiPromise({url: url, key: url, store: Sefaria._textsStore}); + this._textsStore[ref] = apiObject; return apiObject; }, getAllTranslationsWithText: async function(ref) { - let returnObj = await Sefaria.getTextsFromAPIV3(ref, [{language: 'translation', versionTitle: 'all'}], false); + let returnObj = await Sefaria.getTextsFromAPIV3(ref, [{languageFamilyName: 'translation', versionTitle: 'all'}], false); return Sefaria._sortVersionsIntoBuckets(returnObj.versions); }, + getPrimaryAndTranslationFromVersions: function(versions) { + let primary, translation; + if (versions.length === 1) { + primary = versions[0]; + translation = {text: []}; + } else if (versions[0].isPrimary && !versions[1].isSource) { + [primary, translation] = versions; + } else { + [translation, primary] = versions; + } + return [primary, translation]; + }, + _adaptApiResponse: function(versionsResponse) { + /** + * takes an api-v3 texts response for primary and translation versions, and adapt it to the expected 'old' result. + * it adds the texts to 'he' and 'text', and the sources to 'sources' and 'heSources' + */ + const versions = versionsResponse.versions; + const [primary, translation] = Sefaria.getPrimaryAndTranslationFromVersions(versions); + ({ text: versionsResponse.text, versionTitle: versionsResponse.versionTitle, direction: versionsResponse.translationDirection, languageFamilyName: versionsResponse.translationLang, status: versionsResponse.versionStatus } = translation); + ({ text: versionsResponse.he, versionTitle: versionsResponse.heVersionTitle, direction: versionsResponse.primaryDirection, languageFamilyName: versionsResponse.primaryLang, status: versionsResponse.heVersionStatus } = primary); + if (translation.sources && !translation.sources.every(source => source === translation.sources[0])) { + versionsResponse.sources = translation.sources; + } + if (primary.sources && primary.sources.every(source => source === primary.sources[0])) { + versionsResponse.heSources = primary.sources; + } + }, + _findInVresions: function (query, versions) { + query = Object.fromEntries( + Object.entries(query).filter(([_, value]) => value != null) // filters also undefined + ); + return versions.reduce((maxObj, current) => ( + Object.entries(query).every(([key, value]) => current[key] === value) && + (current.priority || 0) > (maxObj?.priority ?? -1) ? + current : maxObj + ), null) + }, + _getVersionObjects: async function(ref, primaryVersionObj, translationVersionObj, translationLanguagePreference) { + const versions = await Sefaria.getVersions(ref); + const flatVersions = Object.values(versions).flat(); + primaryVersionObj = Sefaria._findInVresions({...primaryVersionObj, isPrimary: true}, flatVersions); + if (primaryVersionObj) { + const {languageFamilyName, versionTitle} = primaryVersionObj; + primaryVersionObj = {languageFamilyName, versionTitle}; + } else { + primaryVersionObj = {languageFamilyName: 'primary'} + } + if (translationVersionObj.versionTitle) { + translationVersionObj = Sefaria._findInVresions({...translationVersionObj, isSource: false}, flatVersions); + } + if (!translationVersionObj?.versionTitle) { + let requiredVersion; + const preferredTranslation = Sefaria.versionPreferences.getVersionPref(ref)?.en; + if (preferredTranslation) { + requiredVersion = Sefaria._findInVresions({isSource: false, versionTitle: preferredTranslation}, flatVersions); + } + if (!requiredVersion && translationLanguagePreference) { + const langVersions = versions[translationLanguagePreference] || []; + requiredVersion = Sefaria._findInVresions({isSource: false}, langVersions); + } + if (requiredVersion) { + const {languageFamilyName, versionTitle} = requiredVersion; + translationVersionObj = {languageFamilyName, versionTitle}; + } else { + translationVersionObj = {languageFamilyName: 'translation'}; + } + } + return [primaryVersionObj, translationVersionObj]; + }, + _getPrimaryAndTranslationText: async function(ref, primaryVersionObj, translationVersionObj, translationLanguagePreference) { + // versionObjs are objects with language and versionTitle + const requiredVersions = await Sefaria._getVersionObjects(ref, primaryVersionObj, translationVersionObj, translationLanguagePreference); + const versionsResponse = await Sefaria.getTextsFromAPIV3(ref, requiredVersions, true, 'wrap_all_entities'); + Sefaria._adaptApiResponse(versionsResponse); + return versionsResponse; + }, + getTextFromCurrVersions: async function(ref, currVersions, translationLanguagePreference, withContext) { + let {he, en} = currVersions; + if (!he?.languageFamilyName) {he = {languageFamilyName: 'primary'};} + if (!en?.languageFamilyName) {en = {languageFamilyName: 'translation'};} + let data = await Sefaria._getPrimaryAndTranslationText(ref, he, en, translationLanguagePreference); + if (withContext && data.textDepth === data.sections.length) { + const {text, he, alts} = await Sefaria.getTextFromCurrVersions(data.sectionRef, currVersions, translationLanguagePreference); + data = { + ...data, + text, + he, + alts, + }; + } + this._saveText(data) + return data; + }, _bulkSheets: {}, getBulkSheets: function(sheetIds) { if (sheetIds.length === 0) { return Promise.resolve({}); } @@ -589,7 +684,7 @@ Sefaria = extend(Sefaria, { this._api(Sefaria.apiHost + this._textUrl(ref, settings), function(data) { //save versions and then text so both caches have updated versions if(data?.versions?.length){ - let versions = this._saveVersions(data.sectionRef, data.versions); + let versions = this.saveVersions(data.sectionRef, data.versions); data.versions = this._makeVersions(versions, false); } this._saveText(data, settings); @@ -639,7 +734,7 @@ Sefaria = extend(Sefaria, { if(!versionsInCache) { const url = Sefaria.apiHost + "/api/texts/versions/" + Sefaria.normRef(ref); await this._ApiPromise(url).then(d => { - this._saveVersions(ref, d); + this.saveVersions(ref, d); }); } return Promise.resolve(this._versions[ref]); @@ -769,7 +864,7 @@ Sefaria = extend(Sefaria, { _makeVersions: function(versions, byLang){ return byLang ? versions : Object.values(versions).flat(); }, - _saveVersions: function(ref, versions){ + saveVersions: function(ref, versions){ for (let v of versions) { Sefaria._translateVersions[Sefaria.getTranslateVersionsKey(v.versionTitle, v.language)] = { en: v.versionTitle, @@ -1043,6 +1138,13 @@ Sefaria = extend(Sefaria, { } return data; }, */ + areCurrVersionObjectsEqual: function(version1, version2) { + return version1?.versionTitle === version2?.versionTitle && version1?.languageFamilyName === version2?.languageFamilyName; + }, + areBothVersionsEqual(currVersions1, currVersions2) { + return Sefaria.areCurrVersionObjectsEqual(currVersions1?.en, currVersions2?.en) && + Sefaria.areCurrVersionObjectsEqual(currVersions1?.he, currVersions2?.he); + }, _index: {}, // Cache for text index records index: function(text, index) { if (!index) { @@ -1167,26 +1269,12 @@ Sefaria = extend(Sefaria, { if (versionedKey) { return this._getOrBuildTextData(versionedKey); } return null; }, - getRef: function(ref) { + getRef: function(ref, currVersions=null) { // Returns Promise for parsed ref info + // currVersions is enabling getting text from cache + currVersions = currVersions || {en: null, he: null}; if (!ref) { return Promise.reject(new Error("No Ref!")); } - - const r = this.getRefFromCache(ref); - if (r) return Promise.resolve(r); - - // To avoid an extra API call, first look for any open API calls to this ref (regardless of params) - // todo: Ugly. Breaks abstraction. - const urlPattern = "/api/texts/" + this.normRef(ref); - const openApiCalls = Object.keys(this._ajaxObjects); - for (let i = 0; i < openApiCalls.length; i++) { - if (openApiCalls[i].startsWith(urlPattern)) { - return this._ajaxObjects[openApiCalls[i]]; - } - } - - // If no open calls found, call the texts API. - // Called with context:1 because this is our most common mode, maximize change of saving an API Call - return Sefaria.getText(ref, {context: 1}); + return Sefaria.getTextFromCurrVersions(ref, currVersions); }, ref: function(ref, callback) { if (callback) { @@ -2212,8 +2300,8 @@ _media: {}, sectionString: function(ref) { // Returns a pair of nice strings (en, he) of the sections indicated in ref. e.g., // "Genesis 4" -> "Chapter 4", "Guide for the Perplexed, Introduction" - > "Introduction" - var data = this.getRefFromCache(ref); - var result = { + const data = this.getRefFromCache(ref); + let result = { en: {named: "", numbered: ""}, he: {named: "", numbered: ""} }; @@ -2237,7 +2325,7 @@ _media: {}, result.en.numbered = sections; // Hebrew - var sections = data.heRef.slice(data.heIndexTitle.length+1); + var sections = data.heSectionRef.slice(data.heIndexTitle.length+1); var name = ""; // missing he section names // data.sectionNames.length > 1 ? " " + data.sectionNames[0] : ""; if (data.isComplex) { var numberedSections = data.heRef.slice(data.heTitle.length+1); @@ -2424,7 +2512,7 @@ _media: {}, }, areVersionsEqual(v1, v2) { // v1, v2 are `currVersions` objects stored like {en: ven, he: vhe} - return v1.en == v2.en && v1.he == v2.he; + return v1?.en === v2?.en && v1?.he === v2?.he; }, getSavedItem: ({ ref, versions }) => { return Sefaria.saved.items.find(s => s.ref === ref && Sefaria.areVersionsEqual(s.versions, versions)); @@ -3251,7 +3339,7 @@ Sefaria.unpackDataFromProps = function(props) { let settings = {context: 1, enVersion: panel.enVersion, heVersion: panel.heVersion}; //save versions first, so their new format is also saved on text cache if(panel.text?.versions?.length){ - let versions = Sefaria._saveVersions(panel.text.sectionRef, panel.text.versions); + let versions = Sefaria.saveVersions(panel.text.sectionRef, panel.text.available_versions); panel.text.versions = Sefaria._makeVersions(versions, false); } @@ -3259,7 +3347,7 @@ Sefaria.unpackDataFromProps = function(props) { } if(panel.bookRef){ if(panel.versions?.length){ - let versions = Sefaria._saveVersions(panel.bookRef, panel.versions); + let versions = Sefaria.saveVersions(panel.bookRef, panel.versions); panel.versions = Sefaria._makeVersions(versions, false); } } diff --git a/static/js/sefaria/strings.js b/static/js/sefaria/strings.js index e99b29c75f..b7a94c3b68 100644 --- a/static/js/sefaria/strings.js +++ b/static/js/sefaria/strings.js @@ -404,12 +404,14 @@ const Strings = { "Hide Parasha Aliyot": "עליות לתורה מוסתרות", "Language": "שפה", "Layout": "עימוד", - "Bilingual Layout" : "עימוד דו לשוני", + "Translation": "תרגום", + 'Source with Translation': 'מקור ותרגום', "Color": "צבע", "Font Size" : "גודל גופן", "Aliyot" : "עליות לתורה", "Taamim and Nikkud" : "טעמים וניקוד", "Punctuation" : "פיסוק", + 'Cantilation': 'טעמים', "Show Punctuation": "הצגת סימני פיסוק", "Hide Punctuation": "הסתרת סימני פיסוק", "Show Vowels and Cantillation": "הצג טקסט עם טעמי מקרא וניקוד", diff --git a/static/js/sefaria/textCache.js b/static/js/sefaria/textCache.js new file mode 100644 index 0000000000..8d054a54cd --- /dev/null +++ b/static/js/sefaria/textCache.js @@ -0,0 +1,202 @@ +// this module is for saving text cache +// its interface, TextCache, gets api response and stores it. +// its getter gets ref, language and versionTitle and returns a response-like object (for only one version) +// for saving memory there are different objects for storing general data of books, versions and sections +// the text itself stored in the segment level +// the principle of the structure is that setting and getting will be done by keys, so the extraction will be quick + +import Sefaria from "./sefaria"; + +const BOOK_ATTRS = new Set(['primary_category', 'type', 'indexTitle', 'categories', 'heIndexTitle', 'isComplex', + 'isDependant', 'order', 'collectiveTitle', 'heCollectiveTitle']); +const SECTION_ATTRS = new Set(['sectionRef', 'heSectionRef', 'next', 'prev', 'title', 'book', 'lengths', 'length', + 'textDepth', 'sectionNames', 'addressTypes', 'heTitle', 'titleVariants', 'heTitleVariants', 'index_offsets_by_depth', + 'isSpanning', 'spanningRefs', 'alts', 'firstAvailableSectionRef', 'indexTitle']); +const REF_ATTRS = new Set(['ref', 'heRef', 'sections', 'toSections', 'sectionRef']); + + +class BookAndVersionsDetails { + // this class is an object that stores the book details and its versions details + constructor() { + this.versions = {} + this.general = {} + } + set(response) { + BOOK_ATTRS.forEach((attr) => { + this.general[attr] = response[attr]; + }) + response.versions.forEach((version) => { + const {actualLanguage, versionTitle} = { ...version }; + if (!this.versions[actualLanguage]) { + this.versions[actualLanguage] = {}; + } + this.versions[actualLanguage][versionTitle] = { ...version }; + delete this.versions[actualLanguage][versionTitle].text; + }) + } + get(language, versionTitle) { + let version = { ...this.versions[language]?.[versionTitle] }; + if (Object.keys(version).length) { + return { + versions: [version], + ...this.general + }; + } + } +} + + +class Ref { + // this class is the father of Section and Segment. it handles the data about the ref + constructor() { + this.refAttrs = {}; + } + set(refAttrs) { + REF_ATTRS.forEach((attr) => { + this.refAttrs[attr] = refAttrs[attr]; + }) + } + get_text() { + } + get(language, versionTitle) { + const text = this.get_text(language, versionTitle); + if (text) { + return { + refAttrs: this.refAttrs, + text: text + }; + } + } +} + + +class Segment extends Ref { + // this class is an object stores Segment text + // it is an object of languages, with an object of versionTitles for each language + constructor() { + super(); + this.texts = {}; + } + _addLangIfMissing(language) { + if (!(language in this.texts)) { + this.texts[language] = {}; + } + } + set(language, versionTitle, refAttrs, text) { + super.set(refAttrs) + this._addLangIfMissing(language); + this.texts[language][versionTitle] = text; + } + get_text(language, versionTitle) { + return this.texts[language]?.[versionTitle]; + } +} + + +class Section extends Ref { + // this class is object that stores section - its segment attribute is array of Segments + constructor() { + super(); + this.segments = []; + } + set(refAttrs, segmentObjects) { + super.set(refAttrs); + this.segments = segmentObjects; + } + get_text(language, versionTitle) { + const segmenets = this.segments.map((segment) => { + return segment.get_text(language, versionTitle); + }) + // check that no element in array is undefined + if (segmenets.every((segment) => segment !== undefined)) { + return segmenets; + } + } +} + + +class TextCache { + // this is the interface + // its setter gets the response, and the getter gets ref, language and versionTitle, and returns a response-like object with one version + constructor() { + this.refs = {}; + this.books = {}; + this.sectionsAttrs = {}; + } + _setBookDetails(title, response) { + if (!this.books[title]) { + this.books[title] = new BookAndVersionsDetails(); + } + this.books[title].set(response); + } + _setSectionAttrs(sectionRef, response) { + this.sectionsAttrs[sectionRef] = {}; + SECTION_ATTRS.forEach((attr) => { + this.sectionsAttrs[sectionRef][attr] = response[attr]; + }) + } + _createNew(ref, className) { + if (!(ref in this.refs)) { + this.refs[ref] = new className(); + } + } + _setSegment(ref, language, versionTitle, response, text) { + this._createNew(ref, Segment) + this.refs[ref].set(language, versionTitle, response, text); + } + _setSection(ref, language, versionTitle, response, text) { + this._createNew(ref, Section) + const offset = Sefaria._get_offsets(response); + const start = 1 + offset[0]; + const segments = []; + text.forEach((segmentText, i) => { + const segmentNum = start + i; + const segmentRef = `${ref}:${segmentNum}`; + const refAttrs = { + ref: segmentRef, + heRef: `${response.heRef}:${Sefaria.hebrew.encodeHebrewNumeral(segmentNum)}`, + sections: response.sections.concat(i+1), + toSections: response.sections.concat(i+1), + sectionRef: ref + }; + this._setSegment(segmentRef, language, versionTitle, refAttrs, segmentText); + segments.push(this.refs[segmentRef]) + }) + this.refs[ref].set(response, segments) + } + set(response) { + // the response should be for segment ref or bottom level section ref (not above, and not ranged) + const {indexTitle, ref, sectionRef} = { ...response }; + this._setBookDetails(indexTitle, response); + this._setSectionAttrs(sectionRef, response); + const isSectionLevel = ref === sectionRef; + response.versions.forEach((version) => { + const {actualLanguage, versionTitle} = { ...version }; + if (isSectionLevel) { + this._setSection(ref, actualLanguage, versionTitle, response, version.text); + } else { + this._setSegment(ref, actualLanguage, versionTitle, response, version.text); + } + }) + } + get(ref, language, versionTitle) { + const textObject = this.refs[ref]?.get(language, versionTitle); + if (textObject) { + const sectionAttrs = this.sectionsAttrs[textObject.refAttrs.sectionRef]; + const book = this.books[sectionAttrs.indexTitle].get(language, versionTitle); + if (book) { + const returnObj = { + ...this.books[sectionAttrs.indexTitle].get(language, versionTitle), + ...sectionAttrs, + ...textObject.refAttrs + }; + returnObj.versions[0].text = textObject.text; + return returnObj; + } + } + } +} + + +const CACHE = new TextCache(); +export default CACHE; diff --git a/static/js/sefaria/util.js b/static/js/sefaria/util.js index 31c5406d80..4d32999688 100644 --- a/static/js/sefaria/util.js +++ b/static/js/sefaria/util.js @@ -48,30 +48,22 @@ class Util { static encodeVtitle(vtitle) { return vtitle.replace(/\s/g, '_').replace(/;/g, '%3B'); } + static _getVersionParams(version) { + return `${version.languageFamilyName}|${this.encodeVtitle(version.versionTitle)}`; + } static getUrlVersionsParams(currVersions, i=0) { - currVersions = this.getCurrVersionsWithoutAPIResultFields(currVersions); if (currVersions) { return Object.entries(currVersions) - .filter(([vlang, vtitle]) => !!vtitle) - .map(([vlang, vtitle]) =>`&v${vlang}${i > 1 ? i : ""}=${this.encodeVtitle(vtitle)}`) + .filter(([vlang, version]) => !!version?.versionTitle) + .map(([vlang, version]) =>`&v${vlang}${i > 1 ? i : ""}=${this._getVersionParams(version)}`) .join(""); } else { return ""; } } - static getCurrVersionsWithoutAPIResultFields(currVersions) { - /** - * currVersions can contain fields like `enAPIResult` and `heAPIResult`. - * returns an object without these fields - */ - if (!currVersions) { return currVersions; } - return Object.entries(currVersions).reduce( - (a, [vlang, vtitle]) => { - if (vlang.endsWith("APIResult")) { return a; } - a[vlang] = vtitle; - return a; - }, {} - ); + static getObjectFromUrlParam(param) { + const params = (params) ? param.split('|') : ''; + return {languageFamilyName: params[0], versionTitle: params[1]}; } static decodeVtitle(vtitle) { return vtitle.replace(/_/g, ' ').replace(/%3B/g, ';');