diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7cb3eda..80c6c6d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,440 +1,722 @@ { - "welcomeWispar": "Welcome to Wispar!", - "appDescription": "Your open-source app to stay up-to-date with scientific literature. Let's get you set up quickly!", - "institutionalAccess": "Institutional access", - "setupInstitutionalAccess": "Setup Institutional Access", - "setupInstitutionalAccessLong": "Select your institution's EZproxy to gain access to their journal subscriptions. This will eliminate the need for a VPN or on-premises access.", - "setupSelectInstitution": "Select my institution", - "setupSelectedInstitution": "You have selected:", - "setupLinkZotero": "Link Zotero", - "setupZoteroLong": "Send publications directly to your Zotero library. They will be added to a special Wispar collection.", - "setupLinkMyZotero": "Link my Zotero account", - "setupOtherSettings": "Other Settings", - "setupOtherSettingsLong": "Additional settings are available in the app's settings menu. Access it from the Home screen by tapping the ellipsis menu at the top right corner.", - "setupAlmostSet": "You're All Set!… Almost!", - "setupAlmostSetLong": "Next, we'll take you to the search screen, where you can follow your favorite journals. Don't forget to switch the category menu from 'Articles' to 'Journals'.", - "skip": "Skip", - "getStarted": "Get Started", - "notifications": "Notifications", - "notifPermsGranted": "Permission granted", - "notifPermsNotGranted": "Permission not granted", - "openAppSettings": "Open settings", - "notificationSettingsMessage": "Notifications are permanently disabled. To enable them, go to the app's notification settings.", - "notificationContent": "New articles are available!", - "notificationTitleJournal": "Journals were updated", - "notificationTitleQuery": "Queries were updated", - "numberPublications": "{count, plural, =0{No publications} =1{1 publication} other{{count} publications}}", - "@numberPublications": { - "placeholders": { - "count": {} - } - }, - "showPublicationCount": "Show publication count", - "selectFeed": "Select a feed", - "createCustomFeed": "Create a custom feed", - "done": "Done", - "edit": "Edit", - "clearAll": "Clear all", - "selectAll": "Select all", - "select": "Select", - "customizeFeed": "Customize feed", - "feedName": "Feed name", - "followedJournals": "Followed journals", - "moreJournals": "More journals", - "includeKeywords": "Include keywords", - "excludeKeywords": "Exclude keywords", - "typePressSpace": "Type and press space…", - "errorFeedNameEmpty": "Please enter a name for the feed", - "errorFeedNameAlreadyExists": "A feed with this name already exists", - "errorSelectOneJournal": "Select at least one journal", - "home": "Home", - "@home": { - "description": "The home menu button and the app bar title when in the home screen." - }, - "favorites": "Favorites", - "@favorites": { - "description": "The favorites menu button and the app bar title when in the favorites screen." - }, - "download": "Download", - "@download": { - "description": "The verb to download, without the 'to'." - }, - "delete": "Delete", - "downloads": "Downloads", - "@downloads": { - "description": "The downloads menu button and the app bar title when in the downloads screen." - }, - "downloadSuccessful": "The article was downloaded sucessfully!", - "downloadDeleted": "The article was deleted!", - "noDownloads": "You do not have any downloads.", - "settings": "Settings", - "@settings": { - "description": "The settings option menu button and the app bar title when in the settings screen." - }, - "journalLibraryEmpty": "You are not following any journals. Use the search menu to find and follow journals.", - "@journalLibraryEmpty": { - "description": "Message shown when the user is not following any journals." - }, - "follow": "Follow", - "@follow": { - "description": "The button text shown on journal cards when it is not followed." - }, - "unfollow": "Unfollow", - "@unfollow": { - "description": "The button text shown on journal cards when it is followed." - }, - "library": "Library", - "@library": { - "description": "The library menu button and the app bar title when in the library screen." - }, - "journals": "Journals", - "@journals": { - "description": "The journals menu button and the app bar title when in the journals screen." - }, - "queries": "Queries", - "@queries": { - "description": "Title of the saved search queries tab." - }, - "search": "Search", - "@search": { - "description": "Text shown inside the search screen app bar and for the seach button." - }, - "searchPlaceholder": "Search…", - "@searchPlaceholder": { - "description": "Place holder text in the search input widget." - }, - "filter": "Filter publication cards", - "filterResultsEmpty": "No publication matches your filter", - "filterDownloads": "Filter downloads", - "filterFavorites": "Filter favorites", - "emptyDOIError": "Please enter a DOI", - "searchByQuery": "Search by query", - "searchOpenAlex": "OpenAlex (recommended)", - "source": "Source: {search_provider}", - "everything": "Everything", - "titleAndAbstract": "Title and abstract", - "title": "Title", - "searchIn": "Search in", - "addKeyword": "Add keyword", - "enterKeyword": "Enter a keyword", - "displayName": "Display name", - "citedByCount": "Cited by count", - "worksCount": "Works count", - "publicationDate": "Publication date", - "queryPreview": "Query preview:", - "searchByDOI": "Search by DOI", - "searchByTitle": "Search by title", - "searchByISSN": "Search by ISSN", - "queryHasNoNameError": "You must enter a query name in order to save it.", - "emptySearchQuery": "Please enter a search query", - "journalSearchError": "An error occured while trying to search for journals.", - "moreOptions": "More options", - "saveQuery": "Save this query", - "queryName": "Query name", - "includeInFeed": "Include in feed", - "dateSaved": "Date saved", - "articles": "Articles", - "@articles": { - "description": "As in scientific articles." - }, - "category": "Category", - "publisher": "Publisher", - "publishedin": "Published in", - "subjects": "Subjects", - "latestpublications": "Latest publications", - "authors": "Authors", - "publishedon": "Published on {date}", - "@publishedon": { - "placeholders": { - "date": { - "type": "DateTime", - "format": "yMMMMd" - } - } - }, - "abstract": "Abstract", - "graphicalAbstract": "Graphical abstract", - "copy": "Copy", - "copydoi": "Copy DOI", - "copyAbstract": "Copy abstract", - "copyTitle": "Copy title", - "copyUrl": "Copy URL", - "copiedToClipboard": "Copied to clipboard!", - "doicopied": "DOI copied to clipboard", - "@doicopied": { - "description": "Snackbar shown when the Copy DOI button is pressed." - }, - "issnCopied": "ISSN copied to clipboard", - "apiQueryCopied": "API request copied to clipboard", - "viewarticle": "View article", - "articleViewer": "Article viewer", - "openExternalBrowser": "Open in external browser", - "errorOpenExternalBrowser": "Unable to open the link in a different browser", - "openExternalApp": "Open in external app", - "errorOpenExternalPdfApp": "Unable to open the PDF file in a different app", - "favorite": "Favorite", - "favoriteadded": "added to favorites", - "@favoriteadded": { - "description": "Snackbar shown when an article is added to the user's favorites." - }, - "favoriteremoved": "removed from favorites", - "@favoriteremoved": { - "description": "Snackbar shown when an article is removed from the user's favorites." - }, - "abstractunavailable": "Abstract unavailable. The publisher does not provide abstracts to Crossref. The full text should still be available.", - "@abstractunavailable": { - "description": "Text shown in the place of the abstract when it is unavailable." - }, - "searchresults": "Search results", - "noresultsfound": "No results found.", - "noSavedQueries": "No saved queries.", - "noFavorites": "You have no articles in your favorites. Use the heart icon on publication cards you like to add them to your favorites!", - "@noFavorites": { - "description": "The message shown when the user has no articles in their favorites." - }, - "display": "Display", - "displaySettings": "Display settings", - "appearance": "Appearance", - "theme": "Theme", - "light": "Light", - "dark": "Dark", - "systemtheme": "System theme", - "system": "System", - "pdfTheme": "PDF viewer theme", - "pdfReadingOrientation": "PDF reading orientation", - "vertical": "Vertical", - "horizontal": "Horizontal", - "publicationCard": "Publication card", - "publicationCardSettings": "Publication card settings", - "gestures": "Gestures", - "infoDisplayOnCards": "Information shown on publication cards", - "none": "None", - "addToFavorites": "Add to favorites", - "hidePublication": "Hide publication", - "swipeLeftAction": "Left swipe action", - "swipeRightAction": "Right swipe action", - "showAllAbstracts": "Show all abstracts", - "hideMissingAbstracts": "Hide missing abstracts", - "hideAllAbstracts": "Hide all abstracts", - "licenseInfo": "License information", - "optionsMenu": "Options menu", - "favoriteButton": "Favorite button", - "unpaywallarticle": "The article was provided through Unpaywall", - "@unpaywallarticle": { - "description": "Snackbar shown when an article was fetched from Unpaywall." - }, - "unpaywallArticleAvailable": "Article available via Unpaywall", - "unpaywallChoicePrompt": "This article is available via Unpaywall. How would you like to proceed?", - "useUnpaywall": "Open via Unpaywall", - "goToWebsite": "Go to website", - "forwardedproxy": "Forwarded through your institution proxy", - "@forwardedproxy": { - "description": "Snackbar shown when a request is intercepted and sent through the user's institution EZproxy." - }, - "proxySuccess": "The proxy is successful", - "proxyFailure": "The proxy is not successful", - "proxyLogin": "This is a login page", - "editKnownUrl": "Edit known URL", - "addKnownUrl": "Add known URL", - "redirectsSuccessfully": "Redirects successfully", - "failsToRedirect": "Fails to redirect", - "loginPage": "Login page", - "manageUrlsAndRedirect": "Manage URLs and redirection status", - "selectinstitution": "Select your institution", - "sort": "Sort", - "sortby": "Sort by", - "sortorder": "Sort order", - "followingsince": "Following since {date}", - "@followingsince": { - "placeholders": { - "date": { - "type": "DateTime", - "format": "yMMMMd" - } - } - }, - "lastUpdatedMinutes": "{minutes, plural, one{Last updated 1 minute ago} other{Last updated {minutes} minutes ago}}", - "@lastUpdatedMinutes": { - "placeholders": { - "minutes": { - "type": "int" - } - } - }, - "lastUpdatedHours": "{hours, plural, one{Last updated 1 hour ago} other{Last updated {hours} hours ago}}", - "@lastUpdatedHours": { - "placeholders": { - "hours": { - "type": "int" - } - } - }, - "lastUpdatedDays": "{days, plural, one{Last updated 1 day ago} other{Last updated {days} days ago}}", - "@lastUpdatedDays": { - "placeholders": { - "days": { - "type": "int" - } - } - }, - "pendingUpdate": "Pending update", - "journaltitle": "Journal title", - "followingdate": "Following date", - "ascending": "Ascending", - "descending": "Descending", - "articletitle": "Article title", - "firstauthfamname": "First author family name", - "datepublished": "Date published", - "dateaddedtofavorites": "Date added to favorites", - "addedtoyourfav": "Added to your favorites on {date}", - "@addedtoyourfav": { - "placeholders": { - "date": { - "type": "DateTime", - "format": "yMMMMd" - } - } - }, - "noinstitution": "No institution", - "buildingfeed": "Building your feed. Please wait…", - "fetchingArticleFromJournal": "Fetching articles from {journalName}.", - "noPublicationFound": "No publications found.", - "failLoadMorePublication": "Failed to load more publications.", - "homeFeedEmpty": "No publications available. Publication cards will be added once you are following at least one journal.", - "@homeFeedEmpty": { - "description": "The message shown when the feed in the home screen is empty." - }, - "sharedMessage": "Shared via the Wispar app", - "@sharedMessage": { - "description": "Default sharing text." - }, - "shareArticle": "Share article", - "hiddenArticles": "Hidden publications", - "noHiddenArticles": "No hidden publications", - "hideArticle": "Hide this publication", - "viewHiddenArticles": "View hidden publications", - "unhideArticle": "Unhide this publication", - "sendToZotero": "Send to Zotero", - "zoteroSettings": "Zotero settings", - "zoteroPermissions1": "Wispar needs both read and write access to your Zotero account to enjoy its integration.", - "zoteroPermissions2": "When creating a new Zotero API key, you must select both \"Allow library access\" and \"Allow write access\".", - "zoteroPermissions3": "Once the API key is created, copy the value and paste it inside the text field below.", - "zoteroCreateKey": "Create a new API key", - "zoteroEnterKey": "Enter an API key", - "@zoteroEnterKey": { - "description": "Hint text shown in the text field where users can enter their Zotero API key." - }, - "zoteroValidKey": "API key saved!", - "@zoteroValidKey": { - "description": "Snackbar shown when a valid Zotero API key has been saved." - }, - "zoteroInvalidKey": "The API key is invalid!", - "@zoteroInvalidKey": { - "description": "Snackbar shown when an attempt to save an invalid Zotero API key is made." - }, - "zoteroApiKeyEmpty": "The Zotero API key has not been set yet. Please configure the API key in the app settings.", - "zoteroArticleSent": "The article was sent to Zotero.", - "zoteroSpecificCollection": "Always send to a specific collection", - "zoteroSelectCollection": "Select a collection", - "noZoteroCollectionSelected": "No collection selected", - "zoteroSpecificCollection2": "Always send to this collection", - "zoteroNewCollection": "New collection", - "zoteroCollectionName": "Collection name", - "create": "Create", - "send": "Send", - "save": "Save", - "savedOn": "Saved on {date}", - "@savedOn": { - "placeholders": { - "date": { - "type": "DateTime", - "format": "yMMMMd" - } - } - }, - "sourceCode": "Source code", - "reportIssue": "Report an issue", - "enabled": "Enabled", - "disabled": "Disabled", - "database": "Database", - "databaseSettings": "Database settings", - "concurrentFetches": "Concurrent API requests: {number}", - "scrapeAbstracts": "Scrape missing abstracts", - "cachedArticleRetentionDays": "Cached articles retention (days)", - "cachedArticleRetentionDaysDesc": "Set how many days to keep cached articles. Older articles will be removed from the database along with their PDFs and graphical abstracts. Favorites, downloaded PDFs, and hidden articles will not be deleted. A value of 0 disables the cleanup function, but orphaned files will still be deleted.", - "cleanupIntervalHint": "Enter number of days (1 to 365)", - "apiFetchInterval": "API fetch interval", - "apiFetchIntervalHint": "Select how often to fetch articles", - "cleanupIntervalInvalidNumber": "Please enter a valid number of days.", - "cleanupIntervalNumberNotBetween": "Please enter a value between 1 and 365.", - "databaseNotFound": "The database file was not found.", - "storagePermissionDenied": "Permission denied.", - "databaseExported": "Database exported successfully!", - "databaseExportFailed": "Failed to export database.", - "selectDBExportLocation": "Select a location where to export the database.", - "exportDatabase": "Export database", - "exportingDatabase": "Exporting the database, please wait.", - "importDatabase": "Import database", - "importingDatabase": "Importing the database, please wait.", - "databaseImportFailed": "Failed to import database.", - "databaseImported": "Database imported successfully!", - "customDatabaseLocation": "Custom database location", - "selectCustomDBLocation": "Select a location where to store the database files", - "currentDBLocation": "Current location: {path}", - "movingDatabase": "Moving the database files. Please wait.", - "databaseMoved": "The database files were moved successfully!", - "databaseMoveFailed": "Unable to move the database files: {error}", - "databaseConflictTitle": "Existing Wispar data found.", - "databaseConflictMessage": "Wispar data was found in the selected folder. Do you want to use the existing files or overwrite them with the current database?", - "useExistingFiles": "Use existing files", - "overwriteFiles": "Overwrite with current database", - "overrideUserAgent": "Override user agent", - "customUserAgent": "Custom user agent", - "language": "Language", - "saveSettings": "Save settings", - "settingsSaved": "Settings saved successfully!", - "privacyPolicy": "Privacy policy", - "about": "About", - "madeBy": "Made by {app_author}", - "donate": "Donate", - "donateMessage": "Help support development of Wispar", - "@donateMessage": { - "description": "Text shown in the subtitle of the Donate button in the settings." - }, - "otherLicense": "Other license", - "unknownLicense": "Unkown license", - "hours": "hours", - "failedLoadMoreResults": "Failed to load more results. Please check the logs and consider reporting the issue on GitHub.", - "errorOccured": "An error occured. Please check the logs and consider reporting the issue on GitHub.", - "logs": "Logs", - "viewLogs": "View logs", - "deleteLogs": "Delete logs", - "logsDeleted": "Logs deleted!", - "logsUnavailable": "No logs available.", - "logCopied": "Log copied to clipboard!", - "saveLogs": "Save logs", - "selectLogsLocation": "Select a location where to save the logs.", - "logsExportedSuccessfully": "Successfully saved the logs!", - "logsExportedError": "Unable to save the logs.", - "shareLogs": "Share logs", - "translate": "Translate", - "noAiApiKeySetError": "No AI API key set. Please go to settings to configure one.", - "translationFailed": "Translation failed", - "showTranslation": "Show translation", - "showOriginal": "Show original", - "swapLanguages": "Swap languages", - "cancel": "Cancel", - "aiSettings": "AI settings", - "hideAiFeatures": "Hide all AI features", - "aiProvider": "AI provider", - "pleaseSelectProvider": "Please select a provider", - "apiKeyLabel": "{providerName} API Key", - "pleaseEnterAiAPIKey": "Please enter an API key for {providerName}", - "overrideBaseUrl": "Override base URL", - "customBaseUrl": "Custom base URL", - "pleaseEnterBaseUrl": "Please enter a base URL", - "invalidUrl": "Invalid URL", - "modelNameLabel": "Enter {provider} model name", + "welcomeWispar": "Welcome to Wispar!", + "@welcomeWispar": {}, + "appDescription": "Your open-source app to stay up-to-date with scientific literature. Let's get you set up quickly!", + "@appDescription": {}, + "institutionalAccess": "Institutional access", + "@institutionalAccess":{}, + "setupInstitutionalAccess": "Setup Institutional Access", + "@setupInstitutionalAccess": {}, + "setupInstitutionalAccessLong": "Select your institution's EZproxy to gain access to their journal subscriptions. This will eliminate the need for a VPN or on-premises access.", + "@setupInstitutionalAccessLong": {}, + "setupSelectInstitution": "Select my institution", + "@setupSelectInstitution": {}, + "setupSelectedInstitution": "You have selected:", + "@setupSelectedInstitution": {}, + "setupLinkZotero": "Link Zotero", + "@setupLinkZotero": {}, + "setupZoteroLong": "Send publications directly to your Zotero library. They will be added to a special Wispar collection.", + "@setupZoteroLong": {}, + "setupLinkMyZotero": "Link my Zotero account", + "@setupLinkMyZotero": {}, + "setupOtherSettings": "Other Settings", + "@setupOtherSettings": {}, + "setupOtherSettingsLong": "Additional settings are available in the app's settings menu. Access it from the Home screen by tapping the ellipsis menu at the top right corner.", + "@setupOtherSettingsLong": {}, + "setupAlmostSet": "You're All Set!… Almost!", + "@setupAlmostSet": {}, + "setupAlmostSetLong": "Next, we'll take you to the search screen, where you can follow your favorite journals. Don't forget to switch the category menu from 'Articles' to 'Journals'.", + "@setupAlmostSetLong": {}, + "skip": "Skip", + "@skip": {}, + "getStarted": "Get Started", + "@getStarted": {}, + "notifications": "Notifications", + "@notifications": {}, + "notifPermsGranted": "Permission granted", + "@notifPermsGranted": {}, + "notifPermsNotGranted": "Permission not granted", + "@notifPermsNotGranted": {}, + "openAppSettings": "Open settings", + "@openAppSettings": {}, + "notificationSettingsMessage": "Notifications are permanently disabled. To enable them, go to the app's notification settings.", + "@notificationSettingsMessage": {}, + "notificationContent": "New articles are available!", + "@notificationContent": {}, + "notificationTitleJournal": "Journals were updated", + "@notificationTitleJournal": {}, + "notificationTitleQuery": "Queries were updated", + "@notificationTitleQuery": {}, + "numberPublications": "{count, plural, =0{No publications} =1{1 publication} other{{count} publications}}", + "@numberPublications": { + "placeholders": { + "count": {} + } + }, + "showPublicationCount": "Show publication count", + "@showPublicationCount":{}, + "selectFeed": "Select a feed", + "@selectFeed": {}, + "createCustomFeed": "Create a custom feed", + "@createCustomFeed": {}, + "done": "Done", + "@done": {}, + "edit": "Edit", + "@edit": {}, + "clearAll": "Clear all", + "@clearAll": {}, + "selectAll": "Select all", + "@selectAll": {}, + "select": "Select", + "@select":{}, + "customizeFeed": "Customize feed", + "@customizeFeed": {}, + "feedName": "Feed name", + "@feedName": {}, + "followedJournals": "Followed journals", + "@followedJournals": {}, + "moreJournals": "More journals", + "@moreJournals": {}, + "includeKeywords": "Include keywords", + "@includeKeywords": {}, + "excludeKeywords": "Exclude keywords", + "@excludeKeywords": {}, + "typePressSpace": "Type and press space…", + "@typePressSpace": {}, + "errorFeedNameEmpty": "Please enter a name for the feed", + "@errorFeedNameEmpty": {}, + "errorFeedNameAlreadyExists": "A feed with this name already exists", + "@errorFeedNameAlreadyExists": {}, + "errorSelectOneJournal": "Select at least one journal", + "@errorSelectOneJournal": {}, + "home": "Home", + "@home": { + "description": "The home menu button and the app bar title when in the home screen." + }, + "favorites": "Favorites", + "@favorites": { + "description": "The favorites menu button and the app bar title when in the favorites screen." + }, + "download": "Download", + "@download": { + "description": "The verb to download, without the 'to'." + }, + "delete": "Delete", + "@delete": {}, + "downloads": "Downloads", + "@downloads": { + "description": "The downloads menu button and the app bar title when in the downloads screen." + }, + "downloadSuccessful": "The article was downloaded sucessfully!", + "@downloadSuccessful": {}, + "downloadDeleted": "The article was deleted!", + "@downloadDeleted": {}, + "noDownloads": "You do not have any downloads.", + "@noDownloads": {}, + "settings": "Settings", + "@settings": { + "description": "The settings option menu button and the app bar title when in the settings screen." + }, + "journalLibraryEmpty": "You are not following any journals. Use the search menu to find and follow journals.", + "@journalLibraryEmpty": { + "description": "Message shown when the user is not following any journals." + }, + "follow": "Follow", + "@follow": { + "description": "The button text shown on journal cards when it is not followed." + }, + "unfollow": "Unfollow", + "@unfollow": { + "description": "The button text shown on journal cards when it is followed." + }, + "library": "Library", + "@library": { + "description": "The library menu button and the app bar title when in the library screen." + }, + "journals": "Journals", + "@journals": { + "description": "The journals menu button and the app bar title when in the journals screen." + }, + "queries": "Queries", + "@queries": { + "description": "Title of the saved search queries tab." + }, + "search": "Search", + "@search": { + "description": "Text shown inside the search screen app bar and for the seach button." + }, + "searchPlaceholder": "Search…", + "@searchPlaceholder": { + "description": "Place holder text in the search input widget." + }, + "filter": "Filter publication cards", + "@filter": {}, + "filterResultsEmpty": "No publication matches your filter", + "@filterResultsEmpty": {}, + "filterDownloads": "Filter downloads", + "@filterDownloads": {}, + "filterFavorites": "Filter favorites", + "@filterFavorites": {}, + "emptyDOIError": "Please enter a DOI", + "@emptyDOIError": {}, + "searchByQuery": "Search by query", + "@searchByQuery": {}, + "searchOpenAlex": "OpenAlex (recommended)", + "@searchOpenAlex": {}, + "source": "Source: {search_provider}", + "@source": {}, + "everything": "Everything", + "@everything": {}, + "titleAndAbstract": "Title and abstract", + "@titleAndAbstract": {}, + "title": "Title", + "@title": {}, + "searchIn": "Search in", + "@searchIn": {}, + "addKeyword": "Add keyword", + "@addKeyword": {}, + "enterKeyword": "Enter a keyword", + "@enterKeyword": {}, + "displayName": "Display name", + "@displayName": {}, + "citedByCount": "Cited by count", + "@citedByCount": {}, + "worksCount": "Works count", + "@worksCount": {}, + "publicationDate": "Publication date", + "@publicationDate": {}, + "publishedAfter": "Published after", + "@publishedAfter":{}, + "publishedBefore": "Published before", + "@publishedBefore":{}, + "publishedBetween": "Published between", + "@publishedBetween":{}, + "noFilter": "No filter", + "@noFilter":{}, + "selectStartDate": "Select start date", + "@selectStartDate":{}, + "selectEndDate": "Select end date", + "@selectEndDate":{}, + "queryPreview": "Query preview:", + "@queryPreview": {}, + "searchByDOI": "Search by DOI", + "@searchByDOI": {}, + "searchByTitle": "Search by title", + "@searchByTitle": {}, + "searchByISSN": "Search by ISSN", + "@searchByISSN": {}, + "queryHasNoNameError": "You must enter a query name in order to save it.", + "@queryHasNoNameError": {}, + "emptySearchQuery": "Please enter a search query", + "@emptySearchQuery": {}, + "journalSearchError": "An error occured while trying to search for journals.", + "@journalSearchError": {}, + "moreOptions": "More options", + "@moreOptions": {}, + "saveQuery": "Save this query", + "@saveQuery": {}, + "queryName": "Query name", + "@queryName": {}, + "includeInFeed": "Include in feed", + "@includeInFeed": {}, + "dateSaved": "Date saved", + "@dateSaved": {}, + "articles": "Articles", + "@articles": { + "description": "As in scientific articles." + }, + "category": "Category", + "@category": {}, + "publisher": "Publisher", + "@publisher": {}, + "publishedin": "Published in", + "@publishedin": {}, + "subjects": "Subjects", + "@subjects": {}, + "latestpublications": "Latest publications", + "@latestpublications": {}, + "authors": "Authors", + "@authors": {}, + "publishedon": "Published on {date}", + "@publishedon": { + "placeholders": { + "date": { + "type": "DateTime", + "format": "yMMMMd" + } + } + }, + "abstract": "Abstract", + "@abstract": {}, + "graphicalAbstract": "Graphical abstract", + "@graphicalAbstract":{}, + "copy": "Copy", + "@copy": {}, + "copydoi": "Copy DOI", + "@copydoi": {}, + "copyAbstract": "Copy abstract", + "@copyAbstract": {}, + "copyTitle": "Copy title", + "@copyTitle": {}, + "copyUrl": "Copy URL", + "@copyUrl": {}, + "copiedToClipboard": "Copied to clipboard!", + "@copiedToClipboard": {}, + "doicopied": "DOI copied to clipboard", + "@doicopied": { + "description": "Snackbar shown when the Copy DOI button is pressed." + }, + "issnCopied": "ISSN copied to clipboard", + "@issnCopied": {}, + "apiQueryCopied": "API request copied to clipboard", + "@apiQueryCopied": {}, + "viewarticle": "View article", + "@viewarticle": {}, + "articleViewer": "Article viewer", + "@articleViewer": {}, + "openExternalBrowser": "Open in external browser", + "@openExternalBrowser": {}, + "errorOpenExternalBrowser": "Unable to open the link in a different browser", + "@errorOpenExternalBrowser": {}, + "openExternalApp": "Open in external app", + "@openExternalApp": {}, + "errorOpenExternalPdfApp": "Unable to open the PDF file in a different app", + "@errorOpenExternalPdfApp": {}, + "favorite": "Favorite", + "@favorite": {}, + "favoriteadded": "added to favorites", + "@favoriteadded": { + "description": "Snackbar shown when an article is added to the user's favorites." + }, + "favoriteremoved": "removed from favorites", + "@favoriteremoved": { + "description": "Snackbar shown when an article is removed from the user's favorites." + }, + "abstractunavailable": "Abstract unavailable. The publisher does not provide abstracts to Crossref. The full text should still be available.", + "@abstractunavailable": { + "description": "Text shown in the place of the abstract when it is unavailable." + }, + "searchresults": "Search results", + "@searchresults": {}, + "noresultsfound": "No results found.", + "@noresultsfound": {}, + "noSavedQueries": "No saved queries.", + "@noSavedQueries": {}, + "noFavorites": "You have no articles in your favorites. Use the heart icon on publication cards you like to add them to your favorites!", + "@noFavorites": { + "description": "The message shown when the user has no articles in their favorites." + }, + "apiSettings":"API settings", + "@apiSettings":{}, + "openAlexApiKey":"OpenAlex API Key", + "@openAlexApiKey":{}, + "openAlexApiKeyDesc":"OpenAlex requires a free API key to access their API. While requests may still work without a key, using one is strongly recommended to avoid rate limits and ensure reliability.", + "@openAlexApiKeyDesc":{}, + "openAlexNoApiKey":"No OpenAlex API key set. Please add one in settings to prevent disruptions.", + "@openAlexNoApiKey":{}, + "display": "Display", + "@display": {}, + "displaySettings": "Display settings", + "@displaySettings": {}, + "appearance": "Appearance", + "@appearance": {}, + "theme": "Theme", + "@theme": {}, + "light": "Light", + "@light": {}, + "dark": "Dark", + "@dark": {}, + "systemtheme": "System theme", + "@systemtheme": {}, + "system": "System", + "@system": {}, + "pdfTheme": "PDF viewer theme", + "@pdfTheme":{}, + "pdfReadingOrientation": "PDF reading orientation", + "@pdfReadingOrientation":{}, + "vertical": "Vertical", + "@vertical":{}, + "horizontal": "Horizontal", + "@horizontal":{}, + "publicationCard": "Publication card", + "@publicationCard": {}, + "publicationCardSettings": "Publication card settings", + "@publicationCardSettings":{}, + "gestures": "Gestures", + "@gestures":{}, + "infoDisplayOnCards":"Information shown on publication cards", + "@infoDisplayOnCards":{}, + "none":"None", + "@none":{}, + "addToFavorites": "Add to favorites", + "@addToFavorites":{}, + "hidePublication":"Hide publication", + "@hidePublication":{}, + "swipeLeftAction": "Left swipe action", + "@swipeLeftAction":{}, + "swipeRightAction": "Right swipe action", + "@swipeRightAction":{}, + "showAllAbstracts": "Show all abstracts", + "@showAllAbstracts": {}, + "hideMissingAbstracts": "Hide missing abstracts", + "@hideMissingAbstracts": {}, + "hideAllAbstracts": "Hide all abstracts", + "@hideAllAbstracts": {}, + "licenseInfo":"License information", + "@licenseInfo":{}, + "optionsMenu": "Options menu", + "@optionsMenu":{}, + "favoriteButton":"Favorite button", + "@favoriteButton":{}, + "unpaywallarticle": "The article was provided through Unpaywall", + "@unpaywallarticle": { + "description": "Snackbar shown when an article was fetched from Unpaywall." + }, + "unpaywallArticleAvailable": "Article available via Unpaywall", + "@unpaywallArticleAvailable":{}, + "unpaywallChoicePrompt":"This article is available via Unpaywall. How would you like to proceed?", + "@unpaywallChoicePrompt":{}, + "useUnpaywall": "Open via Unpaywall", + "@useUnpaywall":{}, + "goToWebsite": "Go to website", + "@goToWebsite":{}, + "forwardedproxy": "Forwarded through your institution proxy", + "@forwardedproxy": { + "description": "Snackbar shown when a request is intercepted and sent through the user's institution EZproxy." + }, + "proxySuccess": "The proxy is successful", + "@proxySuccess":{}, + "proxyFailure": "The proxy is not successful", + "@proxyFailure":{}, + "proxyLogin": "This is a login page", + "@proxyLogin":{}, + "editKnownUrl":"Edit known URL", + "@editKnownUrl":{}, + "addKnownUrl": "Add known URL", + "@addKnownUrl":{}, + "redirectsSuccessfully":"Redirects successfully", + "@redirectsSuccessfully":{}, + "failsToRedirect":"Fails to redirect", + "@failsToRedirect":{}, + "loginPage":"Login page", + "@loginPage":{}, + "manageUrlsAndRedirect": "Manage URLs and redirection status", + "@manageUrlsAndRedirect":{}, + "selectinstitution": "Select your institution", + "@selectinstitution": {}, + "sort": "Sort", + "@sort": {}, + "sortby": "Sort by", + "@sortby": {}, + "sortorder": "Sort order", + "@sortorder": {}, + "followingsince": "Following since {date}", + "@followingsince": { + "placeholders": { + "date": { + "type": "DateTime", + "format": "yMMMMd" + } + } + }, + "lastUpdatedMinutes": "{minutes, plural, one{Last updated 1 minute ago} other{Last updated {minutes} minutes ago}}", + "@lastUpdatedMinutes": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "lastUpdatedHours": "{hours, plural, one{Last updated 1 hour ago} other{Last updated {hours} hours ago}}", + "@lastUpdatedHours": { + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "lastUpdatedDays": "{days, plural, one{Last updated 1 day ago} other{Last updated {days} days ago}}", + "@lastUpdatedDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "pendingUpdate": "Pending update", + "@pendingUpdate": {}, + "journaltitle": "Journal title", + "@journaltitle": {}, + "followingdate": "Following date", + "@followingdate": {}, + "ascending": "Ascending", + "@ascending": {}, + "descending": "Descending", + "@descending": {}, + "articletitle": "Article title", + "@articletitle": {}, + "firstauthfamname": "First author family name", + "@firstauthfamname": {}, + "datepublished": "Date published", + "@datepublished": {}, + "dateaddedtofavorites": "Date added to favorites", + "@dateaddedtofavorites": {}, + "addedtoyourfav": "Added to your favorites on {date}", + "@addedtoyourfav": { + "placeholders": { + "date": { + "type": "DateTime", + "format": "yMMMMd" + } + } + }, + "noinstitution": "No institution", + "@noinstitution": {}, + "buildingfeed": "Building your feed. Please wait…", + "@buildingfeed": {}, + "fetchingArticleFromJournal": "Fetching articles from {journalName}.", + "@fetchingArticleFromJournal": {}, + "noPublicationFound": "No publications found.", + "@noPublicationFound": {}, + "failLoadMorePublication": "Failed to load more publications.", + "@failLoadMorePublication": {}, + "homeFeedEmpty": "No publications available. Publication cards will be added once you are following at least one journal.", + "@homeFeedEmpty": { + "description": "The message shown when the feed in the home screen is empty." + }, + "sharedMessage": "Shared via the Wispar app", + "@sharedMessage": { + "description": "Default sharing text." + }, + "shareArticle": "Share article", + "@shareArticle": {}, + "hiddenArticles": "Hidden publications", + "@hiddenArticles": {}, + "noHiddenArticles": "No hidden publications", + "@noHiddenArticles": {}, + "hideArticle": "Hide this publication", + "@hideArticle": {}, + "viewHiddenArticles": "View hidden publications", + "@viewHiddenArticles": {}, + "unhideArticle": "Unhide this publication", + "@unhideArticle": {}, + "sendToZotero": "Send to Zotero", + "@sendToZotero": {}, + "zoteroSettings": "Zotero settings", + "@zoteroSettings": {}, + "zoteroPermissions1": "Wispar needs both read and write access to your Zotero account to enjoy its integration.", + "@zoteroPermissions1": {}, + "zoteroPermissions2": "When creating a new Zotero API key, you must select both \"Allow library access\" and \"Allow write access\".", + "@zoteroPermissions2": {}, + "zoteroPermissions3": "Once the API key is created, copy the value and paste it inside the text field below.", + "@zoteroPermissions3": {}, + "zoteroCreateKey": "Create a new API key", + "@zoteroCreateKey": {}, + "zoteroEnterKey": "Enter an API key", + "@zoteroEnterKey": { + "description": "Hint text shown in the text field where users can enter their Zotero API key." + }, + "zoteroValidKey": "API key saved!", + "@zoteroValidKey": { + "description": "Snackbar shown when a valid Zotero API key has been saved." + }, + "zoteroInvalidKey": "The API key is invalid!", + "@zoteroInvalidKey": { + "description": "Snackbar shown when an attempt to save an invalid Zotero API key is made." + }, + "zoteroApiKeyEmpty": "The Zotero API key has not been set yet. Please configure the API key in the app settings.", + "@zoteroApiKeyEmpty": {}, + "zoteroArticleSent": "The article was sent to Zotero.", + "@zoteroArticleSent": {}, + "zoteroSpecificCollection": "Always send to a specific collection", + "@zoteroSpecificCollection":{}, + "zoteroSelectCollection": "Select a collection", + "@zoteroSelectCollection":{}, + "noZoteroCollectionSelected": "No collection selected", + "@noZoteroCollectionSelected":{}, + "zoteroSpecificCollection2": "Always send to this collection", + "@zoteroSpecificCollection2":{}, + "zoteroNewCollection":"New collection", + "@zoteroNewCollection":{}, + "zoteroCollectionName":"Collection name", + "@zoteroCollectionName":{}, + "create":"Create", + "@create":{}, + "send": "Send", + "@send":{}, + "save": "Save", + "@save": {}, + "savedOn": "Saved on {date}", + "@savedOn": { + "placeholders": { + "date": { + "type": "DateTime", + "format": "yMMMMd" + } + } + }, + "sourceCode": "Source code", + "@sourceCode": {}, + "reportIssue": "Report an issue", + "@reportIssue": {}, + "enabled": "Enabled", + "@enabled": {}, + "disabled": "Disabled", + "@disabled": {}, + "database": "Database", + "@database": {}, + "databaseSettings": "Database settings", + "@databaseSettings": {}, + "concurrentFetches": "Concurrent API requests: {number}", + "@concurrentFetches": {}, + "scrapeAbstracts": "Scrape missing abstracts", + "@scrapeAbstracts": {}, + "cachedArticleRetentionDays": "Cached articles retention (days)", + "@cachedArticleRetentionDays": {}, + "cachedArticleRetentionDaysDesc": "Set how many days to keep cached articles. Older articles will be removed from the database along with their PDFs and graphical abstracts. Favorites, downloaded PDFs, and hidden articles will not be deleted. A value of 0 disables the cleanup function, but orphaned files will still be deleted.", + "@cachedArticleRetentionDaysDesc":{}, + "cleanupIntervalHint": "Enter number of days (1 to 365)", + "@cleanupIntervalHint": {}, + "apiFetchInterval": "API fetch interval", + "@apiFetchInterval": {}, + "apiFetchIntervalHint": "Select how often to fetch articles", + "@apiFetchIntervalHint": {}, + "cleanupIntervalInvalidNumber": "Please enter a valid number of days.", + "@cleanupIntervalInvalidNumber": {}, + "cleanupIntervalNumberNotBetween": "Please enter a value between 1 and 365.", + "@cleanupIntervalNumberNotBetween": {}, + "databaseNotFound": "The database file was not found.", + "@databaseNotFound": {}, + "storagePermissionDenied": "Permission denied.", + "@storagePermissionDenied": {}, + "databaseExported": "Database exported successfully!", + "@databaseExported": {}, + "databaseExportFailed": "Failed to export database.", + "@databaseExportFailed": {}, + "selectDBExportLocation": "Select a location where to export the database.", + "@selectDBExportLocation": {}, + "exportDatabase": "Export database", + "@exportDatabase": {}, + "exportingDatabase": "Exporting the database, please wait.", + "@exportingDatabase":{}, + "importDatabase": "Import database", + "@importDatabase": {}, + "importingDatabase": "Importing the database, please wait.", + "@importingDatabase":{}, + "databaseImportFailed": "Failed to import database.", + "@databaseImportFailed": {}, + "databaseImported": "Database imported successfully!", + "customDatabaseLocation": "Custom database location", + "@customDatabaseLocation":{}, + "selectCustomDBLocation": "Select a location where to store the database files", + "@selectCustomDBLocation":{}, + "currentDBLocation": "Current location: {path}", + "@currentDBLocation":{}, + "movingDatabase": "Moving the database files. Please wait.", + "@movingDatabase":{}, + "databaseMoved": "The database files were moved successfully!", + "@databaseMoved":{}, + "databaseMoveFailed": "Unable to move the database files: {error}", + "@databaseMoveFailed":{}, + "databaseConflictTitle": "Existing Wispar data found.", + "@databaseConflictTitle":{}, + "databaseConflictMessage": "Wispar data was found in the selected folder. Do you want to use the existing files or overwrite them with the current database?", + "@databaseConflictMessage":{}, + "useExistingFiles": "Use existing files", + "@useExistingFiles":{}, + "overwriteFiles": "Overwrite with current database", + "@overwriteFiles":{}, + "overrideUserAgent": "Override user agent", + "@overrideUserAgent":{}, + "customUserAgent": "Custom user agent", + "@customUserAgent":{}, + "@databaseImported": {}, + "language": "Language", + "@language": {}, + "saveSettings": "Save settings", + "@saveSettings": {}, + "settingsSaved": "Settings saved successfully!", + "@settingsSaved": {}, + "privacyPolicy": "Privacy policy", + "@privacyPolicy": {}, + "about": "About", + "@about": {}, + "madeBy": "Made by {app_author}", + "@madeBy": {}, + "donate": "Donate", + "@donate": {}, + "donateMessage": "Help support development of Wispar", + "@donateMessage": { + "description": "Text shown in the subtitle of the Donate button in the settings." + }, + "otherLicense": "Other license", + "@otherLicense": {}, + "unknownLicense": "Unkown license", + "@unknownLicense": {}, + "hours": "hours", + "@hours": {}, + "failedLoadMoreResults": "Failed to load more results. Please check the logs and consider reporting the issue on GitHub.", + "@failedLoadMoreResults": {}, + "errorOccured": "An error occured. Please check the logs and consider reporting the issue on GitHub.", + "@errorOccured": {}, + "logs": "Logs", + "@logs": {}, + "viewLogs": "View logs", + "@viewLogs": {}, + "deleteLogs": "Delete logs", + "@deleteLogs": {}, + "logsDeleted": "Logs deleted!", + "@logsDeleted": {}, + "logsUnavailable": "No logs available.", + "@logsUnavailable": {}, + "logCopied": "Log copied to clipboard!", + "@logCopied": {}, + "saveLogs": "Save logs", + "@saveLogs": {}, + "selectLogsLocation": "Select a location where to save the logs.", + "@selectLogsLocation": {}, + "logsExportedSuccessfully": "Successfully saved the logs!", + "@logsExportedSuccessfully": {}, + "logsExportedError": "Unable to save the logs.", + "@logsExportedError": {}, + "shareLogs": "Share logs", + "@shareLogs": {}, + "translate": "Translate", + "@translate":{}, + "noAiApiKeySetError": "No AI API key set. Please go to settings to configure one.", + "@noAiApiKeySetError":{}, + "translationFailed": "Translation failed", + "@translationFailed":{}, + "showTranslation": "Show translation", + "@showTranslation":{}, + "showOriginal": "Show original", + "@showOriginal":{}, + "swapLanguages": "Swap languages", + "@swapLanguages":{}, + "cancel":"Cancel", + "@cancel":{}, + "aiSettings": "AI settings", + "@aiSettings":{}, + "hideAiFeatures": "Hide all AI features", + "@hideAiFeatures":{}, + "aiProvider": "AI provider", + "@aiProvider":{}, + "pleaseSelectProvider": "Please select a provider", + "@pleaseSelectProvider":{}, + "apiKeyLabel": "{providerName} API Key", + "@apiKeyLabel": {}, + "pleaseEnterAiAPIKey": "Please enter an API key for {providerName}", + "@pleaseEnterAiAPIKey":{}, + "overrideBaseUrl": "Override base URL", + "@overrideBaseUrl":{}, + "customBaseUrl": "Custom base URL", + "@customBaseUrl":{}, + "pleaseEnterBaseUrl": "Please enter a base URL", + "@pleaseEnterBaseUrl":{}, + "invalidUrl": "Invalid URL", + "@invalidUrl":{}, + "modelNameLabel": "Enter {provider} model name", + "@modelNameLabel":{}, "pleaseEnterModelName": "Please enter the {provider} model name.", "aiTemperature": "Temperature", "aiCustomPrompts": "Custom translation prompts", diff --git a/lib/models/feed_filter_entity.dart b/lib/models/feed_filter_entity.dart index 341baa6..b10afaf 100644 --- a/lib/models/feed_filter_entity.dart +++ b/lib/models/feed_filter_entity.dart @@ -4,6 +4,9 @@ class FeedFilter { final String include; final String exclude; final Set journals; + final String? dateMode; + final String? dateAfter; + final String? dateBefore; final String dateCreated; FeedFilter({ @@ -12,6 +15,9 @@ class FeedFilter { required this.include, required this.exclude, required this.journals, + this.dateMode, + this.dateAfter, + this.dateBefore, required this.dateCreated, }); } diff --git a/lib/models/openAlex_works_models.dart b/lib/models/openAlex_works_models.dart index 93023fb..323cc30 100644 --- a/lib/models/openAlex_works_models.dart +++ b/lib/models/openAlex_works_models.dart @@ -67,17 +67,22 @@ class OpenAlexWorks { } String? reconstructAbstract(Map? invertedIndex) { - if (invertedIndex == null) return null; + if (invertedIndex == null || invertedIndex.isEmpty) return null; - int maxIndex = invertedIndex.values - .expand((positions) => positions) - .reduce((a, b) => a > b ? a : b); + final allPositions = + invertedIndex.values.expand((positions) => positions as List); + + if (allPositions.isEmpty) return null; + + int maxIndex = allPositions.reduce((a, b) => a > b ? a : b); List words = List.filled(maxIndex + 1, '', growable: false); invertedIndex.forEach((word, positions) { - for (int pos in positions) { - words[pos] = word; + for (int pos in (positions as List)) { + if (pos <= maxIndex) { + words[pos] = word; + } } }); diff --git a/lib/screens/api_settings_screen.dart b/lib/screens/api_settings_screen.dart new file mode 100644 index 0000000..510b932 --- /dev/null +++ b/lib/screens/api_settings_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:wispar/services/openAlex_api.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; + +class ApiSettingsScreen extends StatefulWidget { + const ApiSettingsScreen({super.key}); + + @override + State createState() => ApiSettingsScreenState(); +} + +class ApiSettingsScreenState extends State { + final TextEditingController _openAlexKeyController = TextEditingController(); + bool passwordVisible = false; + + bool _scrapeAbstracts = true; // Default to scraping missing abstracts + int _fetchInterval = 6; // Default API fetch to 6 hours + int _concurrentFetches = 3; // Default to 3 concurrent fetches + + bool _overrideUserAgent = false; + final TextEditingController _userAgentController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadKeys(); + } + + @override + void dispose() { + _userAgentController.dispose(); + super.dispose(); + } + + Future _loadKeys() async { + final prefs = await SharedPreferences.getInstance(); + + final openAlexKey = prefs.getString('openalex_api_key') ?? ''; + final fetchInterval = prefs.getInt('fetchInterval') ?? 6; + final scrapeAbstracts = prefs.getBool('scrapeAbstracts') ?? true; + final concurrentFetches = prefs.getInt('concurrentFetches') ?? 3; + final overrideUserAgent = prefs.getBool('overrideUserAgent') ?? false; + final customUserAgent = prefs.getString('customUserAgent') ?? ''; + + setState(() { + _openAlexKeyController.text = openAlexKey; + _fetchInterval = fetchInterval; + _scrapeAbstracts = scrapeAbstracts; + _concurrentFetches = concurrentFetches; + _overrideUserAgent = overrideUserAgent; + _userAgentController.text = customUserAgent; + }); + } + + Future _saveKeys() async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setString( + 'openalex_api_key', _openAlexKeyController.text.trim()); + + await prefs.setInt('fetchInterval', _fetchInterval); + await prefs.setBool('scrapeAbstracts', _scrapeAbstracts); + await prefs.setInt('concurrentFetches', _concurrentFetches); + await prefs.setBool('overrideUserAgent', _overrideUserAgent); + if (_overrideUserAgent) { + await prefs.setString('customUserAgent', _userAgentController.text); + } + + OpenAlexApi.apiKey = _openAlexKeyController.text.trim(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.settingsSaved)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.apiSettings), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.openAlexApiKeyDesc, + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () { + launchUrl( + Uri.parse('https://openalex.org/settings/api'), + ); + }, + child: Text(AppLocalizations.of(context)!.zoteroCreateKey)), + const SizedBox(height: 16), + TextField( + controller: _openAlexKeyController, + obscureText: !passwordVisible, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.openAlexApiKey, + border: OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + passwordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + passwordVisible = !passwordVisible; + }); + }, + ), + ), + onChanged: (value) {}, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + isExpanded: true, + initialValue: _fetchInterval, + onChanged: (int? newValue) { + setState(() { + _fetchInterval = newValue!; + }); + }, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.apiFetchInterval, + hintText: AppLocalizations.of(context)!.apiFetchIntervalHint, + ), + items: [ + DropdownMenuItem( + value: 3, + child: Text('3 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 6, + child: Text('6 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 12, + child: Text('12 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 24, + child: Text('24 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 48, + child: Text('48 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 72, + child: Text('72 ${AppLocalizations.of(context)!.hours}'), + ), + ], + ), + SizedBox(height: 16), + Text( + AppLocalizations.of(context)! + .concurrentFetches(_concurrentFetches), + ), + Slider( + value: _concurrentFetches.toDouble(), + min: 1, + max: 5, + divisions: 4, + label: _concurrentFetches.toString(), + onChanged: (double value) { + setState(() { + _concurrentFetches = value.toInt(); + }); + }, + ), + SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context)!.scrapeAbstracts, + ), + Switch( + value: _scrapeAbstracts, + onChanged: (bool value) async { + setState(() { + _scrapeAbstracts = value; + }); + }, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(AppLocalizations.of(context)!.overrideUserAgent), + Switch( + value: _overrideUserAgent, + onChanged: (bool value) { + setState(() { + _overrideUserAgent = value; + }); + }, + ), + ], + ), + if (_overrideUserAgent) + TextFormField( + controller: _userAgentController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.customUserAgent, + hintText: + "Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:140.0) Gecko/140.0 Firefox/140.0", + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _saveKeys, + child: Text(AppLocalizations.of(context)!.saveSettings), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/article_search_results_screen.dart b/lib/screens/article_search_results_screen.dart index 2ecba2e..879145d 100644 --- a/lib/screens/article_search_results_screen.dart +++ b/lib/screens/article_search_results_screen.dart @@ -35,7 +35,7 @@ class ArticleSearchResultsScreenState final ScrollController _scrollController = ScrollController(); bool _isLoadingMore = false; bool _hasMoreResults = true; - int _currentOpenAlexPage = 1; + int _currentOpenAlexPage = 2; SwipeAction _swipeLeftAction = SwipeAction.hide; SwipeAction _swipeRightAction = SwipeAction.favorite; @@ -135,14 +135,16 @@ class ArticleSearchResultsScreenState widget.queryParams['scope'] ?? 1, widget.queryParams['sortField'], widget.queryParams['sortOrder'], + widget.queryParams['dateFilter'], page: _currentOpenAlexPage, ); if (newResults.isNotEmpty) { _currentOpenAlexPage++; + + hasMore = newResults.length >= 25; } else { hasMore = false; - _isLoadingMore = false; } } diff --git a/lib/screens/database_settings_screen.dart b/lib/screens/database_settings_screen.dart index d16731e..8a082d6 100644 --- a/lib/screens/database_settings_screen.dart +++ b/lib/screens/database_settings_screen.dart @@ -1,14 +1,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; -import '../services/logs_helper.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/services/database_helper.dart'; import 'package:flutter/services.dart'; class DatabaseSettingsScreen extends StatefulWidget { @@ -23,16 +23,11 @@ class DatabaseSettingsScreenState extends State { final logger = LogsService().logger; final _formKey = GlobalKey(); - bool _scrapeAbstracts = true; // Default to scraping missing abstracts int _cleanupThreshold = 90; // Default for cleanup interval - int _fetchInterval = 6; // Default API fetch to 6 hours - int _concurrentFetches = 3; // Default to 3 concurrent fetches + final TextEditingController _cleanupThresholdController = TextEditingController(); - bool _overrideUserAgent = false; - final TextEditingController _userAgentController = TextEditingController(); - bool _useCustomPath = false; String? _customDatabasePath; String? @@ -50,11 +45,6 @@ class DatabaseSettingsScreenState extends State { setState(() { // Load the values from SharedPreferences if available _cleanupThreshold = prefs.getInt('cleanupThreshold') ?? 90; - _fetchInterval = prefs.getInt('fetchInterval') ?? 6; - _scrapeAbstracts = prefs.getBool('scrapeAbstracts') ?? true; - _concurrentFetches = prefs.getInt('concurrentFetches') ?? 3; - _overrideUserAgent = prefs.getBool('overrideUserAgent') ?? false; - _userAgentController.text = prefs.getString('customUserAgent') ?? ''; _useCustomPath = prefs.getBool('useCustomDatabasePath') ?? false; _customDatabasePath = prefs.getString('customDatabasePath'); _customDatabaseBookmark = prefs.getString('customDatabaseBookmark'); @@ -67,13 +57,6 @@ class DatabaseSettingsScreenState extends State { if (_formKey.currentState?.validate() ?? false) { SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setInt('cleanupThreshold', _cleanupThreshold); - await prefs.setInt('fetchInterval', _fetchInterval); - await prefs.setBool('scrapeAbstracts', _scrapeAbstracts); - await prefs.setInt('concurrentFetches', _concurrentFetches); - await prefs.setBool('overrideUserAgent', _overrideUserAgent); - if (_overrideUserAgent) { - await prefs.setString('customUserAgent', _userAgentController.text); - } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.settingsSaved)), @@ -537,7 +520,6 @@ class DatabaseSettingsScreenState extends State { @override void dispose() { _cleanupThresholdController.dispose(); - _userAgentController.dispose(); super.dispose(); } @@ -556,6 +538,12 @@ class DatabaseSettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Text( + AppLocalizations.of(context)! + .cachedArticleRetentionDaysDesc, + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), TextFormField( controller: _cleanupThresholdController, keyboardType: TextInputType.number, @@ -586,119 +574,6 @@ class DatabaseSettingsScreenState extends State { return null; }, ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)! - .cachedArticleRetentionDaysDesc, - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - isExpanded: true, - initialValue: _fetchInterval, - onChanged: (int? newValue) { - setState(() { - _fetchInterval = newValue!; - }); - }, - decoration: InputDecoration( - labelText: - AppLocalizations.of(context)!.apiFetchInterval, - hintText: - AppLocalizations.of(context)!.apiFetchIntervalHint, - ), - items: [ - DropdownMenuItem( - value: 3, - child: - Text('3 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 6, - child: - Text('6 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 12, - child: - Text('12 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 24, - child: - Text('24 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 48, - child: - Text('48 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 72, - child: - Text('72 ${AppLocalizations.of(context)!.hours}'), - ), - ], - ), - SizedBox(height: 16), - Text( - AppLocalizations.of(context)! - .concurrentFetches(_concurrentFetches), - ), - Slider( - value: _concurrentFetches.toDouble(), - min: 1, - max: 5, - divisions: 4, - label: _concurrentFetches.toString(), - onChanged: (double value) { - setState(() { - _concurrentFetches = value.toInt(); - }); - }, - ), - SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - AppLocalizations.of(context)!.scrapeAbstracts, - ), - Switch( - value: _scrapeAbstracts, - onChanged: (bool value) async { - setState(() { - _scrapeAbstracts = value; - }); - }, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(AppLocalizations.of(context)!.overrideUserAgent), - Switch( - value: _overrideUserAgent, - onChanged: (bool value) { - setState(() { - _overrideUserAgent = value; - }); - }, - ), - ], - ), - if (_overrideUserAgent) - TextFormField( - controller: _userAgentController, - decoration: InputDecoration( - labelText: - AppLocalizations.of(context)!.customUserAgent, - hintText: - "Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:140.0) Gecko/140.0 Firefox/140.0", - ), - ), const SizedBox(height: 16), FilledButton( onPressed: _saveSettings, @@ -706,7 +581,6 @@ class DatabaseSettingsScreenState extends State { ), const SizedBox(height: 64), const Divider(), - const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 50bac9b..a6d830d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -126,7 +126,14 @@ class HomeScreenState extends State { _currentFeedName = match.name; }); _applyAdvancedFilters( - match.name, match.journals, match.include, match.exclude); + match.name, + match.journals, + match.include, + match.exclude, + match.dateMode, + match.dateAfter, + match.dateBefore, + ); } } else { setState(() { @@ -436,9 +443,17 @@ class HomeScreenState extends State { return CustomizeFeedBottomSheet( followedJournals: followedJournals, moreJournals: unfollowedJournals, - onApply: (String feedName, Set journals, String include, - String exclude) { - _applyAdvancedFilters(feedName, journals, include, exclude); + onApply: ( + String feedName, + Set journals, + String include, + String exclude, + String? dateMode, + String? dateAfter, + String? dateBefore, + ) { + _applyAdvancedFilters(feedName, journals, include, exclude, + dateMode, dateAfter, dateBefore); }, ); }, @@ -641,7 +656,14 @@ class HomeScreenState extends State { } void _applyAdvancedFilters( - String feedName, Set journals, String include, String exclude) { + String feedName, + Set journals, + String include, + String exclude, + String? dateMode, + String? dateAfter, + String? dateBefore, + ) { if (journals.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -680,6 +702,31 @@ class HomeScreenState extends State { ? true : excludeWords.every((word) => !content.contains(word)); + if (dateMode != null && dateMode != 'none') { + final pubDate = pub.publishedDate; + if (pubDate == null) return false; + + if (dateMode == 'after' && dateAfter != null) { + final afterDate = DateTime.parse(dateAfter); + if (!pubDate.isAfter(afterDate)) return false; + } + + if (dateMode == 'before' && dateBefore != null) { + final beforeDate = DateTime.parse(dateBefore); + if (!pubDate.isBefore(beforeDate)) return false; + } + + if (dateMode == 'between' && + dateAfter != null && + dateBefore != null) { + final afterDate = DateTime.parse(dateAfter); + final beforeDate = DateTime.parse(dateBefore); + if (pubDate.isBefore(afterDate) || pubDate.isAfter(beforeDate)) { + return false; + } + } + } + return matchesInclude && matchesExclude; }).toList(); @@ -708,7 +755,14 @@ class HomeScreenState extends State { if (match.name != 'Home') { _applyAdvancedFilters( - match.name, match.journals, match.include, match.exclude); + match.name, + match.journals, + match.include, + match.exclude, + match.dateMode, + match.dateAfter, + match.dateBefore, + ); } } @@ -826,13 +880,28 @@ class HomeScreenState extends State { initialExclude: filter.exclude, initialSelectedJournals: filter.journals, + initialDateMode: filter.dateMode, + initialDateAfter: filter.dateAfter, + initialDateBefore: + filter.dateBefore, feedId: filter.id, - onApply: (String feedName, - Set journals, - String include, - String exclude) { - _applyAdvancedFilters(feedName, - journals, include, exclude); + onApply: ( + String feedName, + Set journals, + String include, + String exclude, + String? dateMode, + String? dateAfter, + String? dateBefore, + ) { + _applyAdvancedFilters( + feedName, + journals, + include, + exclude, + dateMode, + dateAfter, + dateBefore); }, ); }, @@ -893,7 +962,10 @@ class HomeScreenState extends State { filter.name, filter.journals, filter.include, - filter.exclude); + filter.exclude, + filter.dateMode, + filter.dateAfter, + filter.dateBefore); } } : null, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ff71ad8..9beef96 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:wispar/screens/zotero_settings_screen.dart'; +import 'package:wispar/screens/api_settings_screen.dart'; import 'package:wispar/screens/ai_settings_screen.dart'; import 'package:wispar/screens/database_settings_screen.dart'; import 'package:wispar/screens/display_settings_screen.dart'; @@ -97,6 +98,16 @@ class SettingsScreenState extends State { MaterialPageRoute( builder: (context) => const PublicationCardSettingsScreen())), ), + _buildTile( + icon: Icons.api_outlined, + label: AppLocalizations.of(context)!.apiSettings, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ApiSettingsScreen(), + ), + ), + ), _buildTile( icon: Icons.lock_open_outlined, label: 'Unpaywall', diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index d943bd1..1e176c9 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -64,7 +64,7 @@ class DatabaseHelper { Future initDatabase() async { String databasePath = await getDbPath(); - return openDatabase(databasePath, version: 9, onOpen: (db) async { + return openDatabase(databasePath, version: 10, onOpen: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, onCreate: (db, version) async { await db.execute('PRAGMA foreign_keys = ON'); @@ -269,6 +269,17 @@ class DatabaseHelper { ALTER TABLE articles ADD COLUMN graphAbstractPath TEXT; '''); } + if (oldVersion < 10) { + await db.execute(''' + ALTER TABLE feed_filters ADD COLUMN date_mode TEXT; + '''); + await db.execute(''' + ALTER TABLE feed_filters ADD COLUMN date_after TEXT; + '''); + await db.execute(''' + ALTER TABLE feed_filters ADD COLUMN date_before TEXT; + '''); + } }); } @@ -1273,6 +1284,9 @@ class DatabaseHelper { required String include, required String exclude, required Set journals, + String? dateMode, + String? dateAfter, + String? dateBefore, }) async { final db = await database; @@ -1281,6 +1295,9 @@ class DatabaseHelper { 'includedKeywords': include, 'excludedKeywords': exclude, 'journals': journals.join(','), + 'date_mode': dateMode, + 'date_after': dateAfter, + 'date_before': dateBefore, }); } @@ -1298,6 +1315,9 @@ class DatabaseHelper { include: row['includedKeywords'] ?? '', exclude: row['excludedKeywords'] ?? '', journals: (row['journals'] ?? '').split(',').toSet(), + dateMode: row['date_mode'], + dateAfter: row['date_after'], + dateBefore: row['date_before'], dateCreated: row['dateCreated'], ); }).toList(); @@ -1309,6 +1329,9 @@ class DatabaseHelper { required String include, required String exclude, required Set journals, + String? dateMode, + String? dateAfter, + String? dateBefore, }) async { final db = await database; @@ -1319,6 +1342,9 @@ class DatabaseHelper { 'includedKeywords': include, 'excludedKeywords': exclude, 'journals': journals.join(','), + 'date_mode': dateMode, + 'date_after': dateAfter, + 'date_before': dateBefore, }, where: 'id = ?', whereArgs: [id], diff --git a/lib/services/feed_api.dart b/lib/services/feed_api.dart index 84e6338..6e69226 100644 --- a/lib/services/feed_api.dart +++ b/lib/services/feed_api.dart @@ -1,13 +1,16 @@ import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; -import '../models/crossref_journals_works_models.dart' as journalWorks; -import '../models/openAlex_works_models.dart'; +import 'package:wispar/models/crossref_journals_works_models.dart' + as journalWorks; +import 'package:wispar/models/openAlex_works_models.dart'; class FeedApi { static const String baseUrl = 'https://api.crossref.org'; static const String journalsEndpoint = '/journals'; static const String worksEndpoint = '/works'; static const String email = 'mailto=wispar-app@protonmail.com'; + static String? openAlexApiKey; static const String baseUrlOpenAlex = 'https://api.openalex.org'; static const String worksEndpointOpenAlex = '/works?'; @@ -51,8 +54,10 @@ class FeedApi { static Future> getSavedQueryOpenAlex( String query) async { + final prefs = await SharedPreferences.getInstance(); + openAlexApiKey = prefs.getString('openalex_api_key'); final url = Uri.parse( - '$baseUrlOpenAlex$worksEndpointOpenAlex$query&per-page=50&$email'); + '$baseUrlOpenAlex$worksEndpointOpenAlex$query&per-page=50${openAlexApiKey != null && openAlexApiKey!.isNotEmpty ? '&api_key=$openAlexApiKey' : ''}'); final response = await http.get(url); diff --git a/lib/services/openAlex_api.dart b/lib/services/openAlex_api.dart index c2aedbb..772ebc3 100644 --- a/lib/services/openAlex_api.dart +++ b/lib/services/openAlex_api.dart @@ -1,30 +1,56 @@ import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; -import '../models/openAlex_works_models.dart'; -import '../models/crossref_journals_works_models.dart' as journalWorks; +import 'package:wispar/models/openAlex_works_models.dart'; +import 'package:wispar/models/crossref_journals_works_models.dart' + as journalWorks; class OpenAlexApi { static const String baseUrl = 'https://api.openalex.org'; static const String worksEndpoint = '/works?'; - static const String email = 'mailto=wispar-app@protonmail.com'; + static String? apiKey; - static Future> getOpenAlexWorksByQuery( - String query, int scope, String? sortField, String? sortOrder, - {int page = 1} // Default to page 1 - ) async { - final scopeMap = { - 1: 'search=', // Everything - 2: 'filter=title_and_abstract.search:', // Title and Abstract - 3: 'filter=title.search:', // Title only - 4: 'filter=abstract.search:', // Abstract only + static Future> getOpenAlexWorksByQuery(String query, + int scope, String? sortField, String? sortOrder, String? dateFilter, + {int page = 1}) async { + final prefs = await SharedPreferences.getInstance(); + apiKey = prefs.getString('openalex_api_key'); + + Map scopeMap = { + 1: '', // Everything + 2: 'title_and_abstract.search:', // Title and Abstract + 3: 'title.search:', // Title only + 4: 'abstract.search:', // Abstract only }; - String searchField = scopeMap[scope] ?? 'search='; - String sortBy = sortField != null ? '&sort=$sortField' : ''; - String orderBy = sortOrder != null ? ':$sortOrder' : ''; + String searchPart; + String filterPart = ''; + + if (scope == 1) { + searchPart = 'search=$query'; + } else { + searchPart = ''; + filterPart = 'filter=${scopeMap[scope]}$query'; + } + + if (dateFilter != null && dateFilter.isNotEmpty) { + if (filterPart.isEmpty) { + filterPart = 'filter=$dateFilter'; + } else { + filterPart += ',$dateFilter'; + } + } + + String sortPart = ''; + if (sortField != null && sortOrder != null) { + sortPart = '&sort=$sortField:$sortOrder'; + } - String apiUrl = - '$baseUrl$worksEndpoint$searchField$query$sortBy$orderBy&$email&page=$page'; + String apiUrl = '$baseUrl/works?$searchPart' + '${filterPart.isNotEmpty ? '&$filterPart' : ''}' + '$sortPart' + '${apiKey != null && apiKey!.isNotEmpty ? '&api_key=$apiKey' : ''}' + '&page=$page'; final response = await http.get(Uri.parse(apiUrl)); diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index dc5065b..860f5a7 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -1,26 +1,41 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../services/openAlex_api.dart'; -import '../screens/article_search_results_screen.dart'; -import '../models/crossref_journals_works_models.dart' as journalsWorks; -import '../services/database_helper.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/services/openAlex_api.dart'; +import 'package:wispar/screens/article_search_results_screen.dart'; +import 'package:wispar/models/crossref_journals_works_models.dart' + as journals_works; +import 'package:wispar/services/database_helper.dart'; class OpenAlexSearchForm extends StatefulWidget { + const OpenAlexSearchForm({super.key}); + @override - _OpenAlexSearchFormState createState() => _OpenAlexSearchFormState(); + OpenAlexSearchFormState createState() => OpenAlexSearchFormState(); } -class _OpenAlexSearchFormState extends State { +class OpenAlexSearchFormState extends State { + bool _hasApiKey = true; List> queryParts = []; String searchScope = 'Everything'; String selectedSortField = '-'; String selectedSortOrder = '-'; + DateTime? _publishedAfter; + DateTime? _publishedBefore; + + String _dateMode = 'none'; // bool _filtersExpanded = false; bool saveQuery = false; List controllers = []; final TextEditingController queryNameController = TextEditingController(); + @override + void initState() { + super.initState(); + _checkApiKey(); + } + void _addQueryPart(String type) { setState(() { if (queryParts.isNotEmpty && queryParts.last['type'] != 'operator') { @@ -80,6 +95,26 @@ class _OpenAlexSearchFormState extends State { }); } + Future _pickDate(BuildContext context, bool isAfter) async { + final initialDate = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + if (isAfter) { + _publishedAfter = picked; + } else { + _publishedBefore = picked; + } + }); + } + } + void _executeSearch() async { String query = queryParts.map((part) => part['value']).join(' '); final scopeMap = { @@ -92,6 +127,20 @@ class _OpenAlexSearchFormState extends State { String? sortField = selectedSortField == '-' ? null : selectedSortField; String? sortOrder = selectedSortOrder == '-' ? null : selectedSortOrder; + String? dateFilter; + + String formatDate(DateTime d) => d.toIso8601String().split('T')[0]; + + if (_dateMode == 'after' && _publishedAfter != null) { + dateFilter = "from_publication_date:${formatDate(_publishedAfter!)}"; + } else if (_dateMode == 'before' && _publishedBefore != null) { + dateFilter = "to_publication_date:${formatDate(_publishedBefore!)}"; + } else if (_dateMode == 'between' && + _publishedAfter != null && + _publishedBefore != null) { + dateFilter = "from_publication_date:${formatDate(_publishedAfter!)}," + "to_publication_date:${formatDate(_publishedBefore!)}"; + } try { showDialog( @@ -103,7 +152,7 @@ class _OpenAlexSearchFormState extends State { ); final dbHelper = DatabaseHelper(); - List results = []; + List results = []; if (saveQuery) { final queryName = queryNameController.text.trim(); if (queryName != '') { @@ -126,13 +175,27 @@ class _OpenAlexSearchFormState extends State { selectedSortOrder = ':$sortOrder'; } - queryString = '$searchField$query$selectedSortBy$selectedSortOrder'; + String datePart = ''; + + if (dateFilter != null && dateFilter.isNotEmpty) { + if (searchField.startsWith('filter=')) { + datePart = ',$dateFilter'; + } else { + datePart = '&filter=$dateFilter'; + } + } + + queryString = '$searchField$query' + '$datePart' + '$selectedSortBy' + '$selectedSortOrder'; await dbHelper.saveSearchQuery(queryName, queryString, 'OpenAlex'); results = await OpenAlexApi.getOpenAlexWorksByQuery( query, scope, sortField, sortOrder, + dateFilter, ); } else { Navigator.pop(context); @@ -145,11 +208,7 @@ class _OpenAlexSearchFormState extends State { } } else { results = await OpenAlexApi.getOpenAlexWorksByQuery( - query, - scope, - sortField, - sortOrder, - ); + query, scope, sortField, sortOrder, dateFilter); } Navigator.pop(context); Navigator.push( @@ -170,6 +229,15 @@ class _OpenAlexSearchFormState extends State { } } + Future _checkApiKey() async { + final prefs = await SharedPreferences.getInstance(); + final key = prefs.getString('openalex_api_key') ?? ''; + + setState(() { + _hasApiKey = key.isNotEmpty; + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -178,6 +246,20 @@ class _OpenAlexSearchFormState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!_hasApiKey) + Center( + child: Text( + AppLocalizations.of(context)!.openAlexNoApiKey, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: Colors.red, + ), + ), + ), + const SizedBox( + height: 8, + ), DropdownButtonFormField( initialValue: searchScope, onChanged: (String? newValue) { @@ -233,10 +315,6 @@ class _OpenAlexSearchFormState extends State { value: "cited_by_count", child: Text(AppLocalizations.of(context)!.citedByCount)), - DropdownMenuItem( - value: "works_count", - child: - Text(AppLocalizations.of(context)!.worksCount)), DropdownMenuItem( value: "publication_date", child: Text( @@ -278,6 +356,74 @@ class _OpenAlexSearchFormState extends State { ), ], ), + SizedBox(height: 16), + + DropdownButtonFormField( + initialValue: _dateMode, + onChanged: (value) { + setState(() { + _dateMode = value!; + _publishedAfter = null; + _publishedBefore = null; + }); + }, + items: [ + DropdownMenuItem( + value: 'none', + child: Text(AppLocalizations.of(context)!.noFilter)), + DropdownMenuItem( + value: 'after', + child: Text(AppLocalizations.of(context)!.publishedAfter)), + DropdownMenuItem( + value: 'before', + child: Text(AppLocalizations.of(context)!.publishedBefore)), + DropdownMenuItem( + value: 'between', + child: + Text(AppLocalizations.of(context)!.publishedBetween)), + ], + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: AppLocalizations.of(context)!.publicationDate), + ), + + SizedBox(height: 10), + + if (_dateMode == 'after' || _dateMode == 'between') + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text( + _publishedAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _publishedAfter!.toIso8601String().split('T')[0], + ), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, true), + ), + ), + + if (_dateMode == 'before' || _dateMode == 'between') + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_publishedBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _publishedBefore!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, false), + ), + ), SizedBox(height: 10), // Dynamic query builder @@ -437,8 +583,8 @@ class _OpenAlexSearchFormState extends State { ), floatingActionButton: FloatingActionButton( onPressed: _executeSearch, - child: Icon(Icons.search), shape: CircleBorder(), + child: Icon(Icons.search), ), ); } diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index 074c5fa..9845500 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../services/crossref_api.dart'; -import '../screens/article_search_results_screen.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/screens/article_search_results_screen.dart'; +import 'package:wispar/services/database_helper.dart'; class QuerySearchForm extends StatefulWidget { // The key allows to access the state of the form from outside - const QuerySearchForm({Key? key}) : super(key: key); + const QuerySearchForm({super.key}); @override QuerySearchFormState createState() => QuerySearchFormState(); @@ -14,6 +14,9 @@ class QuerySearchForm extends StatefulWidget { class QuerySearchFormState extends State { final GlobalKey _formKey = GlobalKey(); + DateTime? _createdAfter; + DateTime? _createdBefore; + String _dateMode = 'none'; bool isAdvancedSearchVisible = false; bool saveQuery = false; int selectedSortBy = 0; @@ -192,6 +195,25 @@ class QuerySearchFormState extends State { super.dispose(); } + Future _pickDate(BuildContext context, bool isAfter) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + if (isAfter) { + _createdAfter = picked; + } else { + _createdBefore = picked; + } + }); + } + } + void submitForm() async { // Gather all input values, ignoring empty fields final Map queryParams = {}; @@ -213,8 +235,9 @@ class QuerySearchFormState extends State { if (affiliation.isNotEmpty) queryParams['query.affiliation'] = affiliation; final bibliographic = bibliographicController.text.trim(); - if (bibliographic.isNotEmpty) + if (bibliographic.isNotEmpty) { queryParams['query.bibliographic'] = bibliographic; + } final degree = degreeController.text.trim(); if (degree.isNotEmpty) queryParams['query.degree'] = degree; @@ -230,19 +253,22 @@ class QuerySearchFormState extends State { } final eventAcronym = eventAcronymController.text.trim(); - if (eventAcronym.isNotEmpty) + if (eventAcronym.isNotEmpty) { queryParams['query.event-acronym'] = eventAcronym; + } final eventLocation = eventLocationController.text.trim(); - if (eventLocation.isNotEmpty) + if (eventLocation.isNotEmpty) { queryParams['query.event-location'] = eventLocation; + } final eventName = eventNameController.text.trim(); if (eventName.isNotEmpty) queryParams['query.event-name'] = eventName; final eventSponsor = eventSponsorController.text.trim(); - if (eventSponsor.isNotEmpty) + if (eventSponsor.isNotEmpty) { queryParams['query.event-sponsor'] = eventSponsor; + } final eventTheme = eventThemeController.text.trim(); if (eventTheme.isNotEmpty) queryParams['query.event-theme'] = eventTheme; @@ -251,16 +277,19 @@ class QuerySearchFormState extends State { if (funderName.isNotEmpty) queryParams['query.funder-name'] = funderName; final publisherLocation = publisherLocationController.text.trim(); - if (publisherLocation.isNotEmpty) + if (publisherLocation.isNotEmpty) { queryParams['query.publisher-location'] = publisherLocation; + } final standardsBodyAcronym = standardsBodyAcronymController.text.trim(); - if (standardsBodyAcronym.isNotEmpty) + if (standardsBodyAcronym.isNotEmpty) { queryParams['query.standards-body-acronym'] = standardsBodyAcronym; + } final standardsBodyName = standardsBodyNameController.text.trim(); - if (standardsBodyName.isNotEmpty) + if (standardsBodyName.isNotEmpty) { queryParams['query.standards-body-name'] = standardsBodyName; + } // Handle sorting options if (selectedSortBy != 0) { @@ -289,6 +318,27 @@ class QuerySearchFormState extends State { queryParams['order'] = orderOptions[selectedSortOrder]; } + String? dateFilter; + + String formatDate(DateTime d) => d.toIso8601String().split('T')[0]; + + if (_dateMode == 'after' && _createdAfter != null) { + dateFilter = 'from-created-date:${formatDate(_createdAfter!)}'; + } else if (_dateMode == 'before' && _createdBefore != null) { + dateFilter = 'until-created-date:${formatDate(_createdBefore!)}'; + } else if (_dateMode == 'between' && + _createdAfter != null && + _createdBefore != null) { + dateFilter = 'from-created-date:${formatDate(_createdAfter!)},' + 'until-created-date:${formatDate(_createdBefore!)}'; + } + + if (dateFilter != null) { + queryParams['filter'] = queryParams.containsKey('filter') + ? '${queryParams['filter']},$dateFilter' + : dateFilter; + } + // Show loading indicator showDialog( context: context, @@ -416,6 +466,75 @@ class QuerySearchFormState extends State { ), ), SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _dateMode, + onChanged: (value) { + setState(() { + _dateMode = value!; + _createdAfter = null; + _createdBefore = null; + }); + }, + items: [ + DropdownMenuItem( + value: 'none', + child: Text(AppLocalizations.of(context)!.noFilter), + ), + DropdownMenuItem( + value: 'after', + child: Text(AppLocalizations.of(context)!.publishedAfter), + ), + DropdownMenuItem( + value: 'before', + child: Text(AppLocalizations.of(context)!.publishedBefore), + ), + DropdownMenuItem( + value: 'between', + child: Text(AppLocalizations.of(context)!.publishedBetween), + ), + ], + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.publicationDate, + border: OutlineInputBorder(), + ), + ), + + SizedBox(height: 8), + + if (_dateMode == 'after' || _dateMode == 'between') + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_createdAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _createdAfter!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, true), + ), + ), + + if (_dateMode == 'before' || _dateMode == 'between') + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_createdBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _createdBefore!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, false), + ), + ), + SizedBox(height: 16), // Sort by and sort order fields Row( children: [ diff --git a/lib/widgets/custom_feed_bottom_sheet.dart b/lib/widgets/custom_feed_bottom_sheet.dart index 9e625be..ebebd30 100644 --- a/lib/widgets/custom_feed_bottom_sheet.dart +++ b/lib/widgets/custom_feed_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/services/database_helper.dart'; class CustomizeFeedBottomSheet extends StatefulWidget { final List followedJournals; @@ -10,12 +10,18 @@ class CustomizeFeedBottomSheet extends StatefulWidget { Set journals, String include, String exclude, + String dateMode, + String? dateAfter, + String? dateBefore, ) onApply; final String? initialName; final String? initialInclude; final String? initialExclude; final Set? initialSelectedJournals; + final String? initialDateMode; + final String? initialDateAfter; + final String? initialDateBefore; final int? feedId; const CustomizeFeedBottomSheet( @@ -27,14 +33,17 @@ class CustomizeFeedBottomSheet extends StatefulWidget { this.initialInclude, this.initialExclude, this.initialSelectedJournals, + this.initialDateMode, + this.initialDateAfter, + this.initialDateBefore, this.feedId}); @override - _CustomizeFeedBottomSheetState createState() => - _CustomizeFeedBottomSheetState(); + CustomizeFeedBottomSheetState createState() => + CustomizeFeedBottomSheetState(); } -class _CustomizeFeedBottomSheetState extends State { +class CustomizeFeedBottomSheetState extends State { late TextEditingController _nameController; late TextEditingController _includeController; late TextEditingController _excludeController; @@ -48,10 +57,24 @@ class _CustomizeFeedBottomSheetState extends State { Set _selectedJournals = {}; bool _showMoreJournals = false; + DateTime? _publishedDateAfter; + DateTime? _publishedDateBefore; + String _dateMode = 'none'; + @override void initState() { super.initState(); + _dateMode = widget.initialDateMode ?? 'none'; + + if (widget.initialDateAfter != null) { + _publishedDateAfter = DateTime.parse(widget.initialDateAfter!); + } + + if (widget.initialDateBefore != null) { + _publishedDateBefore = DateTime.parse(widget.initialDateBefore!); + } + _nameController = TextEditingController(text: widget.initialName ?? ''); _includeController = TextEditingController(text: widget.initialInclude ?? ''); @@ -116,6 +139,25 @@ class _CustomizeFeedBottomSheetState extends State { }); } + Future _pickDate(BuildContext context, bool isAfter) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + if (isAfter) { + _publishedDateAfter = picked; + } else { + _publishedDateBefore = picked; + } + }); + } + } + @override Widget build(BuildContext context) { return SafeArea( @@ -282,6 +324,82 @@ class _CustomizeFeedBottomSheetState extends State { AppLocalizations.of(context)!.typePressSpace), ), const SizedBox(height: 24), + DropdownButtonFormField( + initialValue: _dateMode, + items: [ + DropdownMenuItem( + value: 'none', + child: + Text(AppLocalizations.of(context)!.noFilter)), + DropdownMenuItem( + value: 'after', + child: Text(AppLocalizations.of(context)! + .publishedAfter)), + DropdownMenuItem( + value: 'before', + child: Text(AppLocalizations.of(context)! + .publishedBefore)), + DropdownMenuItem( + value: 'between', + child: Text(AppLocalizations.of(context)! + .publishedBetween)), + ], + onChanged: (value) { + setState(() { + _dateMode = value!; + if (_dateMode == 'none') { + _publishedDateAfter = null; + _publishedDateBefore = null; + } + }); + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: + AppLocalizations.of(context)!.publicationDate), + ), + const SizedBox(height: 8), + if (_dateMode == 'after' || _dateMode == 'between') + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_publishedDateAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _publishedDateAfter! + .toIso8601String() + .split('T')[0]), + trailing: Icon( + Icons.calendar_today, + color: Theme.of(context).primaryColor, + ), + onTap: () => _pickDate(context, true), + ), + ), + + if (_dateMode == 'before' || _dateMode == 'between') + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_publishedDateBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _publishedDateBefore! + .toIso8601String() + .split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, false), + ), + ), ], ), ), @@ -330,6 +448,9 @@ class _CustomizeFeedBottomSheetState extends State { include: include, exclude: exclude, journals: _selectedJournals, + dateMode: _dateMode, + dateAfter: _publishedDateAfter?.toIso8601String(), + dateBefore: _publishedDateBefore?.toIso8601String(), ); } else { await db.insertFeedFilter( @@ -337,11 +458,21 @@ class _CustomizeFeedBottomSheetState extends State { include: include, exclude: exclude, journals: _selectedJournals, + dateMode: _dateMode, + dateAfter: _publishedDateAfter?.toIso8601String(), + dateBefore: _publishedDateBefore?.toIso8601String(), ); } widget.onApply( - feedName, _selectedJournals, include, exclude); + feedName, + _selectedJournals, + include, + exclude, + _dateMode, + _publishedDateAfter?.toIso8601String(), + _publishedDateBefore?.toIso8601String(), + ); Navigator.pop(context); }, label: Text(AppLocalizations.of(context)!.save), diff --git a/lib/widgets/search_query_card.dart b/lib/widgets/search_query_card.dart index 65fd829..e4e502f 100644 --- a/lib/widgets/search_query_card.dart +++ b/lib/widgets/search_query_card.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:flutter/services.dart'; -import '../services/crossref_api.dart'; -import '../services/openAlex_api.dart'; -import '../services/string_format_helper.dart'; -import '../screens/article_search_results_screen.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/services/openAlex_api.dart'; +import 'package:wispar/services/string_format_helper.dart'; +import 'package:wispar/screens/article_search_results_screen.dart'; +import 'package:wispar/services/database_helper.dart'; class SearchQueryCard extends StatefulWidget { final int queryId; @@ -17,7 +17,7 @@ class SearchQueryCard extends StatefulWidget { final VoidCallback? onDelete; const SearchQueryCard({ - Key? key, + super.key, required this.queryId, required this.queryName, required this.queryParams, @@ -25,13 +25,13 @@ class SearchQueryCard extends StatefulWidget { this.showDeleteButton = false, required this.dateSaved, this.onDelete, - }) : super(key: key); + }); @override - _SearchQueryCardState createState() => _SearchQueryCardState(); + SearchQueryCardState createState() => SearchQueryCardState(); } -class _SearchQueryCardState extends State { +class SearchQueryCardState extends State { bool _includeInFeed = false; late DatabaseHelper databaseHelper; @@ -67,6 +67,11 @@ class _SearchQueryCardState extends State { var response; Map queryMap = {}; String? query; + int scope = 1; + String? sortField; + String? sortOrder; + String? dateFilter; + String? filterValue; if (widget.queryProvider == 'Crossref') { // Convert the params string to the needed mapstring @@ -76,40 +81,48 @@ class _SearchQueryCardState extends State { } else if (widget.queryProvider == 'OpenAlex') { queryMap = Uri.splitQueryString(widget.queryParams); - String? sortField = queryMap['sort']; - String? sortOrder = queryMap['sortOrder']; - int scope = 1; - String? query; - - if (queryMap.containsKey('search') && - !queryMap.containsKey('filter')) { - query = queryMap['search']; - scope = 1; - } else if (queryMap.containsKey('filter')) { - String filterValue = queryMap['filter'] ?? ''; - - if (filterValue.contains('title.search')) { - query = filterValue.replaceFirst('title.search:', '').trim(); - scope = 3; - } else if (filterValue.contains('title_and_abstract')) { - query = filterValue.replaceFirst('title_and_abstract', '').trim(); - scope = 2; - } else if (filterValue.contains('title')) { - query = filterValue.replaceFirst('title', '').trim(); - scope = 3; - } else if (filterValue.contains('abstract')) { - query = filterValue.replaceFirst('abstract', '').trim(); - scope = 4; - } else { - query = filterValue; - scope = 1; - } + String? sortParam = queryMap['sort']; + String? filterValue = queryMap['filter']; + String? searchValue = queryMap['search']; + + sortField = null; + sortOrder = null; + + if (sortParam != null && sortParam.contains(':')) { + final parts = sortParam.split(':'); + sortField = parts[0]; + sortOrder = parts.length > 1 ? parts[1] : null; } - query ??= ''; + query = searchValue ?? ''; + scope = 1; + + if (filterValue != null) { + List filters = filterValue.split(','); + List remainingFilters = []; + + for (var f in filters) { + if (f.startsWith('title.search:')) { + query = f.replaceFirst('title.search:', ''); + scope = 3; + } else if (f.startsWith('title_and_abstract.search:')) { + query = f.replaceFirst('title_and_abstract.search:', ''); + scope = 2; + } else if (f.startsWith('abstract.search:')) { + query = f.replaceFirst('abstract.search:', ''); + scope = 4; + } else if (f.startsWith('from_publication_date:') || + f.startsWith('to_publication_date:')) { + remainingFilters.add(f); + } + } + if (remainingFilters.isNotEmpty) { + dateFilter = remainingFilters.join(','); + } + } response = await OpenAlexApi.getOpenAlexWorksByQuery( - query, scope, sortField, sortOrder); + query ?? '', scope, sortField, sortOrder, dateFilter); } Navigator.pop(context); @@ -129,7 +142,14 @@ class _SearchQueryCardState extends State { return ArticleSearchResultsScreen( initialSearchResults: response, initialHasMore: response.isNotEmpty, - queryParams: {'query': query}, + queryParams: { + 'query': query, + 'scope': scope, + 'sortField': sortField, + 'sortOrder': sortOrder, + 'dateFilter': dateFilter, + 'filter': filterValue, + }, source: widget.queryProvider, ); }