From bb7cf6ba2093c48ce867d4125d027c141af69849 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 27 Jun 2023 13:39:15 +0200 Subject: [PATCH 01/26] (core) Modify prompt so that model may say it cannot help with certain requests. Summary: This tweaks the prompting so that the user's message is given on its own instead of as a docstring within Python. This is so that the prompt makes sense when: - the user asks a question such as "Can you write me a formula which does ...?" rather than describing their formula as a docstring would, or - the user sends a message that doesn't ask for a formula at all (https://grist.slack.com/archives/C0234CPPXPA/p1687699944315069?thread_ts=1687698078.832209&cid=C0234CPPXPA) Also added wording for the model to refuse when the user asks for something that the model cannot do. Because the code (and maybe in some cases the model) for non-ChatGPT models relies on the prompt consisting entirely of Python code produced by the data engine (which no longer contains the user's message) those code paths have been disabled for now. Updating them now seems like undesirable drag, I think it'd be better to revisit this when iteration/experimentation has slowed down and stabilised. Test Plan: Added entries to the formula dataset where the response shouldn't contain a formula, indicated by the value `1` for the new column `no_formula`. This is somewhat successful, as the model does refuse to help in some of the new test cases, but not all. Performance on existing entries also seems a bit worse, but it's hard to distinguish this from random noise. Hopefully this can be remedied in the future with more work, e.g. automatic followup messages containing example inputs and outputs. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3936 --- app/server/lib/Assistance.ts | 52 ++++--- documentation/llm.md | 34 ++--- sandbox/grist/formula_prompt.py | 6 +- sandbox/grist/test_formula_prompt.py | 16 +-- .../data/formula-dataset-index.csv | 131 +++++++++--------- test/formula-dataset/runCompletion_impl.ts | 5 + 6 files changed, 126 insertions(+), 118 deletions(-) diff --git a/app/server/lib/Assistance.ts b/app/server/lib/Assistance.ts index 820213bd..0d732cb8 100644 --- a/app/server/lib/Assistance.ts +++ b/app/server/lib/Assistance.ts @@ -46,7 +46,7 @@ export interface AssistanceSchemaPromptV1Context { /** * A flavor of assistant for use with the OpenAI API. - * Tested primarily with text-davinci-002 and gpt-3.5-turbo. + * Tested primarily with gpt-3.5-turbo. */ export class OpenAIAssistant implements Assistant { private _apiKey: string; @@ -60,8 +60,11 @@ export class OpenAIAssistant implements Assistant { throw new Error('OPENAI_API_KEY not set'); } this._apiKey = apiKey; - this._model = process.env.COMPLETION_MODEL || "text-davinci-002"; + this._model = process.env.COMPLETION_MODEL || "gpt-3.5-turbo-0613"; this._chatMode = this._model.includes('turbo'); + if (!this._chatMode) { + throw new Error('Only turbo models are currently supported'); + } this._endpoint = `https://api.openai.com/v1/${this._chatMode ? 'chat/' : ''}completions`; } @@ -72,25 +75,27 @@ export class OpenAIAssistant implements Assistant { if (messages.length === 0) { messages.push({ role: 'system', - content: 'The user gives you one or more Python classes, ' + - 'with one last method that needs completing. Write the ' + - 'method body as a single code block, ' + - 'including the docstring the user gave. ' + - 'Just give the Python code as a markdown block, ' + - 'do not give any introduction, that will just be ' + - 'awkward for the user when copying and pasting. ' + - 'You are working with Grist, an environment very like ' + - 'regular Python except `rec` (like record) is used ' + - 'instead of `self`. ' + - 'Include at least one `return` statement or the method ' + - 'will fail, disappointing the user. ' + - 'Your answer should be the body of a single method, ' + - 'not a class, and should not include `dataclass` or ' + - '`class` since the user is counting on you to provide ' + - 'a single method. Thanks!' + content: 'You are a helpful assistant for a user of software called Grist. ' + + 'Below are one or more Python classes. ' + + 'The last method needs completing. ' + + "The user will probably give a description of what they want the method (a 'formula') to return. " + + 'If so, your response should include the method body as Python code in a markdown block. ' + + 'Do not include the class or method signature, just the method body. ' + + 'If your code starts with `class`, `@dataclass`, or `def` it will fail. Only give the method body. ' + + 'You can import modules inside the method body if needed. ' + + 'You cannot define additional functions or methods. ' + + 'The method should be a pure function that performs some computation and returns a result. ' + + 'It CANNOT perform any side effects such as adding/removing/modifying rows/columns/cells/tables/etc. ' + + 'It CANNOT interact with files/databases/networks/etc. ' + + 'It CANNOT display images/charts/graphs/maps/etc. ' + + 'If the user asks for these things, tell them that you cannot help. ' + + 'The method uses `rec` instead of `self` as the first parameter.\n\n' + + '```python\n' + + await makeSchemaPromptV1(doc, request) + + '\n```', }); messages.push({ - role: 'user', content: await makeSchemaPromptV1(doc, request), + role: 'user', content: request.text, }); } else { if (request.regenerate) { @@ -257,10 +262,11 @@ function getAssistant() { if (process.env.OPENAI_API_KEY) { return new OpenAIAssistant(); } - if (process.env.HUGGINGFACE_API_KEY) { - return new HuggingFaceAssistant(); - } - throw new Error('Please set OPENAI_API_KEY or HUGGINGFACE_API_KEY'); + // Maintaining this is too much of a burden for now. + // if (process.env.HUGGINGFACE_API_KEY) { + // return new HuggingFaceAssistant(); + // } + throw new Error('Please set OPENAI_API_KEY'); } /** diff --git a/documentation/llm.md b/documentation/llm.md index 3a6b880d..2042ba1c 100644 --- a/documentation/llm.md +++ b/documentation/llm.md @@ -1,35 +1,35 @@ # Using Large Language Models with Grist In this experimental Grist feature, originally developed by Alex Hall, -you can hook up an AI model such as OpenAI's Codex to write formulas for +you can hook up OpenAI's ChatGPT to write formulas for you. Here's how. -First, you need an API key. You'll have best results currently with an -OpenAI model. Visit https://openai.com/api/ and prepare a key, then +First, you need an API key. Visit https://openai.com/api/ and prepare a key, then store it in an environment variable `OPENAI_API_KEY`. -Alternatively, there are many non-proprietary models hosted on Hugging Face. -At the time of writing, none can compare with OpenAI for use with Grist. -Things can change quickly in the world of AI though. So instead of OpenAI, -you can visit https://huggingface.co/ and prepare a key, then -store it in an environment variable `HUGGINGFACE_API_KEY`. - That's all the configuration needed! Currently it is only a backend feature, we are still working on the UI for it. -## Trying other models +## Hugging Face and other OpenAI models (deactivated) -The model used will default to `text-davinci-002` for OpenAI. You can -get better results by setting an environment variable `COMPLETION_MODEL` to -`code-davinci-002` if you have access to that model. +_Not currently available, needs some work to revive. These notes are only preserved as a reminder to ourselves of how this worked._ -The model used will default to `NovelAI/genji-python-6B` for +~~To use a different OpenAI model such as `code-davinci-002` or `text-davinci-003`, +set the environment variable `COMPLETION_MODEL` to the name of the model.~~ + +~~Alternatively, there are many non-proprietary models hosted on Hugging Face. +At the time of writing, none can compare with OpenAI for use with Grist. +Things can change quickly in the world of AI though. So instead of OpenAI, +you can visit https://huggingface.co/ and prepare a key, then +store it in an environment variable `HUGGINGFACE_API_KEY`.~~ + +~~The model used will default to `NovelAI/genji-python-6B` for Hugging Face. There's no particularly great model for this application, but you can try other models by setting an environment variable `COMPLETION_MODEL` to `codeparrot/codeparrot` or -`NinedayWang/PolyCoder-2.7B` or similar. +`NinedayWang/PolyCoder-2.7B` or similar.~~ -If you are hosting a model yourself, host it as Hugging Face does, +~~If you are hosting a model yourself, host it as Hugging Face does, and use `COMPLETION_URL` rather than `COMPLETION_MODEL` to -point to the model on your own server rather than Hugging Face. +point to the model on your own server rather than Hugging Face.~~ diff --git a/sandbox/grist/formula_prompt.py b/sandbox/grist/formula_prompt.py index ba01963f..c3125135 100644 --- a/sandbox/grist/formula_prompt.py +++ b/sandbox/grist/formula_prompt.py @@ -150,7 +150,7 @@ def class_schema(engine, table_id, exclude_col_id=None, lookups=False): return result -def get_formula_prompt(engine, table_id, col_id, description, +def get_formula_prompt(engine, table_id, col_id, _description, include_all_tables=True, lookups=True): result = "" @@ -165,9 +165,7 @@ def get_formula_prompt(engine, table_id, col_id, description, result += " @property\n" result += " # rec is alias for self\n" result += " def {}(rec) -> {}:\n".format(col_id, return_type) - result += ' """\n' - result += '{}\n'.format(indent(description, " ")) - result += ' """\n' + result += " # Please fill in code only after this line, not the `def`\n" return result def indent(text, prefix, predicate=None): diff --git a/sandbox/grist/test_formula_prompt.py b/sandbox/grist/test_formula_prompt.py index d03752d4..9ddefc03 100644 --- a/sandbox/grist/test_formula_prompt.py +++ b/sandbox/grist/test_formula_prompt.py @@ -151,9 +151,7 @@ class Table2: @property # rec is alias for self def new_formula(rec) -> float: - """ - description here - """ + # Please fill in code only after this line, not the `def` ''') def test_get_formula_prompt(self): @@ -183,9 +181,7 @@ class Table1: @property # rec is alias for self def text(rec) -> str: - """ - description here - """ + # Please fill in code only after this line, not the `def` ''') self.assert_prompt("Table2", "ref", '''\ @@ -199,9 +195,7 @@ class Table2: @property # rec is alias for self def ref(rec) -> Table1: - """ - description here - """ + # Please fill in code only after this line, not the `def` ''') self.assert_prompt("Table3", "reflist", '''\ @@ -219,9 +213,7 @@ class Table3: @property # rec is alias for self def reflist(rec) -> List[Table2]: - """ - description here - """ + # Please fill in code only after this line, not the `def` ''') def test_convert_completion(self): diff --git a/test/formula-dataset/data/formula-dataset-index.csv b/test/formula-dataset/data/formula-dataset-index.csv index 56e381c9..1c977a63 100644 --- a/test/formula-dataset/data/formula-dataset-index.csv +++ b/test/formula-dataset/data/formula-dataset-index.csv @@ -1,69 +1,76 @@ -table_id,col_id,doc_id,Description -Contacts,Send_Email,hQHXqAQXceeQBPvRw5sSs1,"Link to compose an email, if there is one" -Contacts,No_Notes,hQHXqAQXceeQBPvRw5sSs1,"Number of notes for this contact" -Category,Contains_archived_project_,hQHXqAQXceeQBPvRw5sSs1,"Whether any projects in this category are archived" -Tasks,Today,hQHXqAQXceeQBPvRw5sSs1,Needs to be done today (or every day) -Tasks,Week_Day,hQHXqAQXceeQBPvRw5sSs1,Full name of deadline weekday -Tasks,period,hQHXqAQXceeQBPvRw5sSs1,Whether this task was modified between (inclusive) the dates in the single row in Settings -Expenses,Month,55Q2EtTbFvB1N6iizLh4Rk,e.g. 2022-01 -Payroll,Date_Range,5pHLanQNThxkEaEJHKJUf5,"The start date, followed by a dash (no spaces) and the end date if there is one. Dates are month/day with no leading zeroes." -Payroll,Per_Hour,5pHLanQNThxkEaEJHKJUf5,The hourly rate of the latest rate for this role and person that started on or before this date -Payroll,Payment,5pHLanQNThxkEaEJHKJUf5,"Total payment amount for hours worked, rounded to the nearest cent." -Payroll_summary_Pay_Period_Person,Dates,5pHLanQNThxkEaEJHKJUf5,"All date ranges in the group, separated by a comma and a space" -People,Full_Name,5pHLanQNThxkEaEJHKJUf5,"e.g. Doe, John" -General_Ledger,Quarter,2YwYBWpREY2a1N2NV7cb55,e.g. 2020 Q4 -General_Ledger,Year,2YwYBWpREY2a1N2NV7cb55,"Just the year of the date, as a string" -Time_Calculator,Time_Worked,np7TVHmuvFcHmo1K8h7Ur4,Formatted as hours:minutes. No leading zeroes for hours. -Time_Calculator,Seconds_Worked,np7TVHmuvFcHmo1K8h7Ur4,"Number of seconds between start/end times, if they're both there" -Funding_Source,Percentage,qprycQa2TVwajAe6Hb3bUZ,Ratio of the amount to the total across all rows -Funding_Source_summary,Debt_to_Equity,qprycQa2TVwajAe6Hb3bUZ,Ratio of the total amounts in the group where the type is Debt vs Equity -Invoices,Client,bReAxyLmzmEQfHF5L5Sc1e,Client's name followed by their address on the next line -Invoices,Hours,bReAxyLmzmEQfHF5L5Sc1e,Total duration in hours across all time logs for this invoice -Invoices,Due,bReAxyLmzmEQfHF5L5Sc1e,30 days after the invoice date -Invoices,Invoice_ID,bReAxyLmzmEQfHF5L5Sc1e,Invoice date followed by the client's name in brackets -Projects,Project_Name,bReAxyLmzmEQfHF5L5Sc1e,"Client name and project name, e.g. John Doe: Big project" -Time_Log,Date,bReAxyLmzmEQfHF5L5Sc1e,Start date if there is one -Time_Log,Duration_hrs_,bReAxyLmzmEQfHF5L5Sc1e,Duration (if there is one) in hours rounded to two decimal places -Time_Log,Duration_min_,bReAxyLmzmEQfHF5L5Sc1e,"Number of minutes between start and end time. If either time is missing, leave blank. If end is before start, give 0." -Filtered_By_Formula,LabelCount,9nNr9uQwoXWAvxcWQDygh6,"1 if the state is CA, otherwise 0" -Objects,Address,pyMHqncEspfZN5zfShCwT8,"City and state, separated by comma space" -Books,search_terms,hdXy57qLiyNf35oNLzzgBG,"Title and author name, with a space in between" -BOM_Items,Cost,e4gEm7dt4cgBMkouVBNMeY,Total cost if both quantity and cost are given -Bill_Of_Materials,Cost,e4gEm7dt4cgBMkouVBNMeY,Total cost -All_Responses,Entry,qvND7WUcuNb2fU4n1vBJ7f,"Name and submitted date in the format ""Name - month-day""" -All_Responses,Month,qvND7WUcuNb2fU4n1vBJ7f,Submitted month (full name) and year -Cap_Table,Common_Stock,iXggjrCPHut9u2BuhJxJkk,"If the class is Options, RSUs, or Option Pool, return 0, otherwise return the fully diluted value." -Cap_Table,Fully_Diluted,iXggjrCPHut9u2BuhJxJkk,"The granted amount, minus the total pool used if the class is Option Pool" -Cap_Table,Fully_Diluted_,iXggjrCPHut9u2BuhJxJkk,Fully diluted as a fraction of the total -Classes,Spots_Left,swLvb3Fic22gVzrdczcAoZ,or Full -Classes,Count,swLvb3Fic22gVzrdczcAoZ,Number of enrollments for this class where the status is Confirmed -All_Survey_Responses,Product_Experience_Score,4ktYzGV1mUipSiQFtkLGqm,"A number based on the experience: +no_formula,table_id,col_id,doc_id,Description +0,Contacts,Send_Email,hQHXqAQXceeQBPvRw5sSs1,"Link to compose an email, if there is one" +0,Contacts,No_Notes,hQHXqAQXceeQBPvRw5sSs1,"Number of notes for this contact" +0,Category,Contains_archived_project_,hQHXqAQXceeQBPvRw5sSs1,"Whether any projects in this category are archived" +0,Tasks,Today,hQHXqAQXceeQBPvRw5sSs1,Needs to be done today (or every day) +0,Tasks,Week_Day,hQHXqAQXceeQBPvRw5sSs1,Full name of deadline weekday +0,Tasks,period,hQHXqAQXceeQBPvRw5sSs1,Whether this task was modified between (inclusive) the dates in the single row in Settings +0,Expenses,Month,55Q2EtTbFvB1N6iizLh4Rk,e.g. 2022-01 +0,Payroll,Date_Range,5pHLanQNThxkEaEJHKJUf5,"The start date, followed by a dash (no spaces) and the end date if there is one. Dates are month/day with no leading zeroes." +0,Payroll,Per_Hour,5pHLanQNThxkEaEJHKJUf5,The hourly rate of the latest rate for this role and person that started on or before this date +0,Payroll,Payment,5pHLanQNThxkEaEJHKJUf5,"Total payment amount for hours worked, rounded to the nearest cent." +0,Payroll_summary_Pay_Period_Person,Dates,5pHLanQNThxkEaEJHKJUf5,"All date ranges in the group, separated by a comma and a space" +0,People,Full_Name,5pHLanQNThxkEaEJHKJUf5,"e.g. Doe, John" +0,General_Ledger,Quarter,2YwYBWpREY2a1N2NV7cb55,e.g. 2020 Q4 +0,General_Ledger,Year,2YwYBWpREY2a1N2NV7cb55,"Just the year of the date, as a string" +0,Time_Calculator,Time_Worked,np7TVHmuvFcHmo1K8h7Ur4,Formatted as hours:minutes. No leading zeroes for hours. +0,Time_Calculator,Seconds_Worked,np7TVHmuvFcHmo1K8h7Ur4,"Number of seconds between start/end times, if they're both there" +0,Funding_Source,Percentage,qprycQa2TVwajAe6Hb3bUZ,Ratio of the amount to the total across all rows +0,Funding_Source_summary,Debt_to_Equity,qprycQa2TVwajAe6Hb3bUZ,Ratio of the total amounts in the group where the type is Debt vs Equity +0,Invoices,Client,bReAxyLmzmEQfHF5L5Sc1e,Client's name followed by their address on the next line +0,Invoices,Hours,bReAxyLmzmEQfHF5L5Sc1e,Total duration in hours across all time logs for this invoice +0,Invoices,Due,bReAxyLmzmEQfHF5L5Sc1e,30 days after the invoice date +0,Invoices,Invoice_ID,bReAxyLmzmEQfHF5L5Sc1e,Invoice date followed by the client's name in brackets +0,Projects,Project_Name,bReAxyLmzmEQfHF5L5Sc1e,"Client name and project name, e.g. John Doe: Big project" +0,Time_Log,Date,bReAxyLmzmEQfHF5L5Sc1e,Start date if there is one +0,Time_Log,Duration_hrs_,bReAxyLmzmEQfHF5L5Sc1e,Duration (if there is one) in hours rounded to two decimal places +0,Time_Log,Duration_min_,bReAxyLmzmEQfHF5L5Sc1e,"Number of minutes between start and end time. If either time is missing, leave blank. If end is before start, give 0." +0,Filtered_By_Formula,LabelCount,9nNr9uQwoXWAvxcWQDygh6,"1 if the state is CA, otherwise 0" +0,Objects,Address,pyMHqncEspfZN5zfShCwT8,"City and state, separated by comma space" +0,Books,search_terms,hdXy57qLiyNf35oNLzzgBG,"Title and author name, with a space in between" +0,BOM_Items,Cost,e4gEm7dt4cgBMkouVBNMeY,Total cost if both quantity and cost are given +0,Bill_Of_Materials,Cost,e4gEm7dt4cgBMkouVBNMeY,Total cost +1,Bill_Of_Materials,Cost,e4gEm7dt4cgBMkouVBNMeY,Calculate the mean cost and add a row showing the variance from the mean +0,All_Responses,Entry,qvND7WUcuNb2fU4n1vBJ7f,"Name and submitted date in the format ""Name - month-day""" +0,All_Responses,Month,qvND7WUcuNb2fU4n1vBJ7f,Submitted month (full name) and year +0,Cap_Table,Common_Stock,iXggjrCPHut9u2BuhJxJkk,"If the class is Options, RSUs, or Option Pool, return 0, otherwise return the fully diluted value." +0,Cap_Table,Fully_Diluted,iXggjrCPHut9u2BuhJxJkk,"The granted amount, minus the total pool used if the class is Option Pool" +0,Cap_Table,Fully_Diluted_,iXggjrCPHut9u2BuhJxJkk,Fully diluted as a fraction of the total +0,Classes,Spots_Left,swLvb3Fic22gVzrdczcAoZ,or Full +0,Classes,Count,swLvb3Fic22gVzrdczcAoZ,Number of enrollments for this class where the status is Confirmed +1,Classes,Count,swLvb3Fic22gVzrdczcAoZ,Add a row at the end with the total number +0,All_Survey_Responses,Product_Experience_Score,4ktYzGV1mUipSiQFtkLGqm,"A number based on the experience: Very Dissatisfied: 1 Somewhat Dissatisfied: 2 Neutral: 3 Somewhat Satisfied: 4 Very Satisfied: 5" -Time_Sheet_Entries_summary_Account_Employee_Month,Total_Spend,oGxD8EnzeVs6vSQK3QBrUv,Total hours worked times hourly rate -Time_Sheets,Title,oGxD8EnzeVs6vSQK3QBrUv,Month number and employee full name separated by a space -All_Products,SKU,sXsBGDTKau1F3fvxkCyoaJ,"Brand code, color code, and size, separated by dashes without spaces" -All_Products,QTY_on_Order,sXsBGDTKau1F3fvxkCyoaJ,Total quantity minus total received quantity across all incoming order line items for this product -All_Products,Stock_Alert,sXsBGDTKau1F3fvxkCyoaJ,"If the amount in stock and on order is more than 5: In Stock +0,Time_Sheet_Entries_summary_Account_Employee_Month,Total_Spend,oGxD8EnzeVs6vSQK3QBrUv,Total hours worked times hourly rate +0,Time_Sheets,Title,oGxD8EnzeVs6vSQK3QBrUv,Month number and employee full name separated by a space +0,All_Products,SKU,sXsBGDTKau1F3fvxkCyoaJ,"Brand code, color code, and size, separated by dashes without spaces" +0,All_Products,QTY_on_Order,sXsBGDTKau1F3fvxkCyoaJ,Total quantity minus total received quantity across all incoming order line items for this product +0,All_Products,Stock_Alert,sXsBGDTKau1F3fvxkCyoaJ,"If the amount in stock and on order is more than 5: In Stock If it's 0: OUT OF STOCK Otherwise: Low Stock" -Incoming_Order_Line_Items,Received_Qty,sXsBGDTKau1F3fvxkCyoaJ,"The quantity, but only if the order is received" -Theaters,Latitude2,dKztiPYamcCpttT1LT1FnU,Coordinate before the comma -Theaters,Longitude,dKztiPYamcCpttT1LT1FnU,Coordinate after the comma and space -Families,Amount_Due,cJcSKdUC3nLNAv4wTjAxA6,"Total charged minus total paid, capped at 0" -Families,Total_Applied,cJcSKdUC3nLNAv4wTjAxA6,Total charge for all paid sessions for this family -Gifts_summary_Occasion_Who_Year,Over_Budget_,dr6epxpXUcy9rsFVUoXTEe,Did we spend more than the budget for this person? -Gifts_summary_Year,Total_Budget,dr6epxpXUcy9rsFVUoXTEe,Total budget for all important dates this year -Leases,Signer,5iMYwmESm33JpEECSqdZk2,The signing tenant for this lease -Apartments,Have_Picture,5iMYwmESm33JpEECSqdZk2,Yes or No depending on if there's a picture -Apartments,Current_Lease,5iMYwmESm33JpEECSqdZk2,The lease for this apartment whose current status is Active -Current_Signers,Lease_Start_Date,5iMYwmESm33JpEECSqdZk2,The start date of the lease for this apartment whose current status is Active -Leases,Lease_End_Date,5iMYwmESm33JpEECSqdZk2,Start date plus the lease term in years minus one day -Tenancies,Minor,5iMYwmESm33JpEECSqdZk2,"1 if the age is less than 18, otherwise 0" -Game_Schedule,Loser,1xJAp2uxM7tFCVUbEofKoF,The team that won fewer sets -Standings,Win_Rate,1xJAp2uxM7tFCVUbEofKoF,Ratio of wins to total games -Standings,Wins,1xJAp2uxM7tFCVUbEofKoF,Number of games won -Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,One month after the issued date if there is one +0,Incoming_Order_Line_Items,Received_Qty,sXsBGDTKau1F3fvxkCyoaJ,"The quantity, but only if the order is received" +0,Theaters,Latitude2,dKztiPYamcCpttT1LT1FnU,Coordinate before the comma +1,Theaters,Latitude2,dKztiPYamcCpttT1LT1FnU,How can I see the coordinates on a map? +0,Theaters,Longitude,dKztiPYamcCpttT1LT1FnU,Coordinate after the comma and space +0,Families,Amount_Due,cJcSKdUC3nLNAv4wTjAxA6,"Total charged minus total paid, capped at 0" +0,Families,Total_Applied,cJcSKdUC3nLNAv4wTjAxA6,Total charge for all paid sessions for this family +0,Gifts_summary_Occasion_Who_Year,Over_Budget_,dr6epxpXUcy9rsFVUoXTEe,Did we spend more than the budget for this person? +0,Gifts_summary_Year,Total_Budget,dr6epxpXUcy9rsFVUoXTEe,Total budget for all important dates this year +0,Leases,Signer,5iMYwmESm33JpEECSqdZk2,The signing tenant for this lease +1,Leases,Signer,5iMYwmESm33JpEECSqdZk2,Show the attached photo of the signing tenant +0,Apartments,Have_Picture,5iMYwmESm33JpEECSqdZk2,Yes or No depending on if there's a picture +0,Apartments,Current_Lease,5iMYwmESm33JpEECSqdZk2,The lease for this apartment whose current status is Active +0,Current_Signers,Lease_Start_Date,5iMYwmESm33JpEECSqdZk2,The start date of the lease for this apartment whose current status is Active +0,Leases,Lease_End_Date,5iMYwmESm33JpEECSqdZk2,Start date plus the lease term in years minus one day +0,Tenancies,Minor,5iMYwmESm33JpEECSqdZk2,"1 if the age is less than 18, otherwise 0" +0,Game_Schedule,Loser,1xJAp2uxM7tFCVUbEofKoF,The team that won fewer sets +0,Standings,Win_Rate,1xJAp2uxM7tFCVUbEofKoF,Ratio of wins to total games +0,Standings,Wins,1xJAp2uxM7tFCVUbEofKoF,Number of games won +0,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,One month after the issued date if there is one +1,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,Hello +1,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,Can you help me? +1,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,How do I create a new table? diff --git a/test/formula-dataset/runCompletion_impl.ts b/test/formula-dataset/runCompletion_impl.ts index 4ac94f8b..c991d821 100644 --- a/test/formula-dataset/runCompletion_impl.ts +++ b/test/formula-dataset/runCompletion_impl.ts @@ -53,6 +53,7 @@ const TEMPLATE_URL = "https://grist-static.com/datasets/grist_dataset_formulai_2 const oldFetch = DEPS.fetch; interface FormulaRec { + no_formula: string; table_id: string; col_id: string; doc_id: string; @@ -170,6 +171,10 @@ where c.colId = ? and t.tableId = ? if (result.state) { history = result.state; } + if (rec.no_formula == "1") { + success = result.suggestedActions.length === 0; + return null; + } suggestedActions = result.suggestedActions; // apply modification const {actionNum} = await activeDoc.applyUserActions(session, suggestedActions); From 71bff105666687cabacd39066c0b04b2020746ab Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 28 Jun 2023 15:15:33 +0200 Subject: [PATCH 02/26] Fix GREP_TESTS unbound error in test_under_docker.sh (#549) Co-authored-by: Florent FAYOLLE --- test/test_under_docker.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_under_docker.sh b/test/test_under_docker.sh index 3e54603c..ba7af76b 100755 --- a/test/test_under_docker.sh +++ b/test/test_under_docker.sh @@ -64,4 +64,4 @@ TEST_ADD_SAMPLES=1 TEST_ACCOUNT_PASSWORD=not-needed \ GRIST_SESSION_COOKIE=grist_test_cookie \ GRIST_TEST_LOGIN=1 \ NODE_PATH=_build:_build/stubs \ - $MOCHA _build/test/deployment/*.js --slow 6000 -g "${GREP_TESTS}" "$@" + $MOCHA _build/test/deployment/*.js --slow 6000 -g "${GREP_TESTS:-}" "$@" From 01069a69b0c394b7a447de48de549ba030d54ca4 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Wed, 28 Jun 2023 22:17:14 +0100 Subject: [PATCH 03/26] adapt tests after switch to parallel runs (#547) Some browser tests are now run in parallel. A few tests have become unreliable, and need a little love. Also, create and save mocha webdriver logs. --- .github/workflows/main.yml | 18 ++++++++++++++++++ test/nbrowser/ColumnOps.ntest.js | 1 - test/nbrowser/CustomWidgetsConfig.ts | 8 ++++---- test/nbrowser/gristUtil-nbrowser.js | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c3df5e7..5960c672 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -98,10 +98,28 @@ jobs: - name: Run main tests without minio and redis if: contains(matrix.tests, ':nbrowser-') run: | + mkdir -p $MOCHA_WEBDRIVER_LOGDIR export GREP_TESTS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/") MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3 env: TESTS: ${{ matrix.tests }} + MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/mocha-webdriver-logs + + - name: Prepare a safe artifact name + if: failure() + run: | + ARTIFACT_NAME=logs-$(echo $TESTS | sed 's/[^-a-zA-Z0-9]/_/g') + echo "Artifact name is '$ARTIFACT_NAME'" + echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV + env: + TESTS: ${{ matrix.tests }} + + - name: Save artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ runner.temp }}/mocha-webdriver-logs # only exists for webdriver tests services: # https://github.com/bitnami/bitnami-docker-minio/issues/16 diff --git a/test/nbrowser/ColumnOps.ntest.js b/test/nbrowser/ColumnOps.ntest.js index 816d7bbb..3bed2e28 100644 --- a/test/nbrowser/ColumnOps.ntest.js +++ b/test/nbrowser/ColumnOps.ntest.js @@ -7,7 +7,6 @@ describe('ColumnOps.ntest', function() { const cleanup = test.setupTestSuite(this); before(async function() { - this.timeout(Math.max(this.timeout(), 30000)); // Long-running test, unfortunately await gu.supportOldTimeyTestCode(); await gu.useFixtureDoc(cleanup, "World.grist", true); await gu.toggleSidePanel('left', 'close'); diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts index 43ae72fe..cefdea0a 100644 --- a/test/nbrowser/CustomWidgetsConfig.ts +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -223,10 +223,10 @@ describe('CustomWidgetsConfig', function () { constructor(public frameSelector = 'iframe') {} // Wait for a frame. public async waitForFrame() { - await driver.wait(() => driver.find(this.frameSelector).isPresent(), 1000); + await driver.wait(() => driver.find(this.frameSelector).isPresent(), 3000); const iframe = driver.find(this.frameSelector); await driver.switchTo().frame(iframe); - await driver.wait(async () => (await driver.find('#ready').getText()) === 'ready', 1000); + await driver.wait(async () => (await driver.find('#ready').getText()) === 'ready', 3000); await driver.switchTo().defaultContent(); } public async content() { @@ -254,7 +254,7 @@ describe('CustomWidgetsConfig', function () { } // Wait for frame to close. public async waitForClose() { - await driver.wait(async () => !(await driver.find(this.frameSelector).isPresent()), 1000); + await driver.wait(async () => !(await driver.find(this.frameSelector).isPresent()), 3000); } // Wait for the onOptions event, and return its value. public async onOptions() { @@ -262,7 +262,7 @@ describe('CustomWidgetsConfig', function () { await driver.switchTo().frame(iframe); // Wait for options to get filled, initially this div is empty, // as first message it should get at least null as an options. - await driver.wait(async () => await driver.find('#onOptions').getText(), 1000); + await driver.wait(async () => await driver.find('#onOptions').getText(), 3000); const text = await driver.find('#onOptions').getText(); await driver.switchTo().defaultContent(); return JSON.parse(text); diff --git a/test/nbrowser/gristUtil-nbrowser.js b/test/nbrowser/gristUtil-nbrowser.js index 4187c25d..72526111 100644 --- a/test/nbrowser/gristUtil-nbrowser.js +++ b/test/nbrowser/gristUtil-nbrowser.js @@ -53,7 +53,7 @@ async function applyPatchesToJquerylikeObject($) { // Adapt common old setup. const test = { setupTestSuite(self, ...args) { - self.timeout(20000); + self.timeout(40000); return setupTestSuite(...args); }, }; From 230e84f48a5f1c516de3472be52e7010ca0e7a55 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Thu, 29 Jun 2023 08:15:14 +0100 Subject: [PATCH 04/26] avoid test files importing other test files (#550) There's a little nest of SelectBy tests that sometimes fail. They are the only tests with an import of a helper function from an other file that contains tests. Such imports have caused trouble with mocha in the past. I'm not sure if that is the case now, but I'd like to eliminate it as a possibility. --- test/nbrowser/RightPanelSelectBy.ts | 20 ++++++-------------- test/nbrowser/SelectByRefList.ts | 6 +----- test/nbrowser/SelectBySummary.ts | 6 +----- test/nbrowser/SelectBySummaryRef.ts | 6 +----- test/nbrowser/gristUtils.ts | 10 ++++++++++ 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/test/nbrowser/RightPanelSelectBy.ts b/test/nbrowser/RightPanelSelectBy.ts index a496e5d2..1cc6df16 100644 --- a/test/nbrowser/RightPanelSelectBy.ts +++ b/test/nbrowser/RightPanelSelectBy.ts @@ -12,9 +12,6 @@ describe('RightPanelSelectBy', function() { const doc = await gu.importFixturesDoc('chimpy', 'nasa', 'Horizon', 'Favorite_Films_With_Linked_Ref.grist', false); await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); await gu.waitForDocToLoad(); - - await gu.toggleSidePanel('right', 'open'); - await driver.find('.test-config-data').click(); } it('should allow linking section with same table', async function() { @@ -24,7 +21,7 @@ describe('RightPanelSelectBy', function() { await driver.findContentWait('.test-treeview-itemHeader', /All/, 2000).click(); await gu.waitForDocToLoad(); - await openSelectByForSection('PERFORMANCES DETAIL'); + await gu.openSelectByForSection('PERFORMANCES DETAIL'); // the dollar in /...record$/ makes sure we match against the table main node and not a ref // columns such as '...record.Film' @@ -52,7 +49,7 @@ describe('RightPanelSelectBy', function() { }); it('should allow to remove link', async function() { - await openSelectByForSection('PERFORMANCES DETAIL'); + await gu.openSelectByForSection('PERFORMANCES DETAIL'); await driver.findContent('.test-select-row', /Select Widget/).click(); await gu.waitForServer(); @@ -67,7 +64,7 @@ describe('RightPanelSelectBy', function() { it('should disallow creating cycles', async function() { - await openSelectByForSection('PERFORMANCES RECORD'); + await gu.openSelectByForSection('PERFORMANCES RECORD'); assert.equal(await driver.findContent('.test-select-row', /Performances detail/).isPresent(), false); }); @@ -81,7 +78,7 @@ describe('RightPanelSelectBy', function() { await gu.addNewSection(/Chart/, /Films/); // open `SELECT BY` - await openSelectByForSection('FILMS'); + await gu.openSelectByForSection('FILMS'); // check that there is a chart and we cannot link from it assert.equal(await gu.getSection('FILMS CHART').isPresent(), true); @@ -96,7 +93,7 @@ describe('RightPanelSelectBy', function() { await gu.getPageItem('Friends').click(); await gu.waitForServer(); await gu.addNewSection(/Table/, /Performances/); - await openSelectByForSection('Performances'); + await gu.openSelectByForSection('Performances'); assert.equal(await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).isPresent(), true); await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).click(); await gu.waitForServer(); @@ -135,7 +132,7 @@ describe('RightPanelSelectBy', function() { await gu.getPageItem('Friends').click(); await gu.waitForServer(); await gu.addNewSection(/Card/, /Films/); - await openSelectByForSection('Films Card'); + await gu.openSelectByForSection('Films Card'); assert.equal(await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).isPresent(), true); await driver.findContent('.test-select-row', /FRIENDS.*Favorite Film/).click(); await gu.waitForServer(); @@ -194,8 +191,3 @@ describe('RightPanelSelectBy', function() { }); }); - -export async function openSelectByForSection(section: string) { - await gu.getSection(section).click(); - await driver.find('.test-right-select-by').click(); -} diff --git a/test/nbrowser/SelectByRefList.ts b/test/nbrowser/SelectByRefList.ts index db3c7f48..7982817c 100644 --- a/test/nbrowser/SelectByRefList.ts +++ b/test/nbrowser/SelectByRefList.ts @@ -2,7 +2,6 @@ import * as _ from 'lodash'; import {addToRepl, assert, driver} from 'mocha-webdriver'; import * as gu from 'test/nbrowser/gristUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils'; -import {openSelectByForSection} from "./RightPanelSelectBy"; describe('SelectByRefList', function() { this.timeout(60000); @@ -16,9 +15,6 @@ describe('SelectByRefList', function() { 'SelectByRefList.grist', false); await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); await gu.waitForDocToLoad(); - - await gu.toggleSidePanel('right'); - await driver.find('.test-config-data').click(); } it('should filter a table selected by ref and reflist columns', async function() { @@ -182,7 +178,7 @@ describe('SelectByRefList', function() { * The values will depend on the link and the last row selected in the driving table. */ async function checkSelectingRecords(selectBy: string, sourceData: string[][], newRow: string[]) { - await openSelectByForSection('LINKTARGET'); + await gu.openSelectByForSection('LINKTARGET'); await driver.findContent('.test-select-row', new RegExp(selectBy + '$')).click(); await gu.waitForServer(); diff --git a/test/nbrowser/SelectBySummary.ts b/test/nbrowser/SelectBySummary.ts index 74987814..26168b56 100644 --- a/test/nbrowser/SelectBySummary.ts +++ b/test/nbrowser/SelectBySummary.ts @@ -3,7 +3,6 @@ import {addToRepl, assert, driver} from 'mocha-webdriver'; import {enterRulePart, findDefaultRuleSet} from 'test/nbrowser/aclTestUtils'; import * as gu from 'test/nbrowser/gristUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils'; -import {openSelectByForSection} from "./RightPanelSelectBy"; describe('SelectBySummary', function() { this.timeout(50000); @@ -17,9 +16,6 @@ describe('SelectBySummary', function() { 'SelectBySummary.grist', false); await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); await gu.waitForDocToLoad(); - - await gu.toggleSidePanel('right', 'open'); - await driver.find('.test-config-data').click(); }); it('should filter a source table selected by a summary table', async function() { @@ -219,7 +215,7 @@ async function checkSelectingRecords( ) { const summarySection = `TABLE1 [by ${groubyColumns.join(', ')}]`; - await openSelectByForSection(targetSection); + await gu.openSelectByForSection(targetSection); await driver.findContent('.test-select-row', summarySection).click(); await gu.waitForServer(); diff --git a/test/nbrowser/SelectBySummaryRef.ts b/test/nbrowser/SelectBySummaryRef.ts index 2e41321a..964a514b 100644 --- a/test/nbrowser/SelectBySummaryRef.ts +++ b/test/nbrowser/SelectBySummaryRef.ts @@ -1,7 +1,6 @@ import {addToRepl, assert, driver, Key} from 'mocha-webdriver'; import * as gu from 'test/nbrowser/gristUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils'; -import {openSelectByForSection} from "./RightPanelSelectBy"; describe('SelectBySummaryRef', function() { this.timeout(20000); @@ -14,9 +13,6 @@ describe('SelectBySummaryRef', function() { 'SelectBySummaryRef.grist', false); await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`); await gu.waitForDocToLoad(); - - await gu.toggleSidePanel('right', 'open'); - await driver.find('.test-config-data').click(); }); it('should give correct options when linking with a summary table with ref/reflist columns', async () => { @@ -174,7 +170,7 @@ describe('SelectBySummaryRef', function() { // Check that the 'Select by' menu in the right panel for the section has the expected options async function checkRightPanelSelectByOptions(section: string, expected: string[]) { - await openSelectByForSection(section); + await gu.openSelectByForSection(section); const actual = await driver.findAll('.test-select-menu .test-select-row', (e) => e.getText()); assert.deepEqual(actual, ['Select Widget', ...expected]); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index c78a2475..d69876c6 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1759,6 +1759,16 @@ export async function openAccessRulesDropdown(): Promise { await driver.findWait('.grist-floating-menu', 1000); } +/** + * Open "Select By" area in creator panel. + */ +export async function openSelectByForSection(section: string) { + await toggleSidePanel('right', 'open'); + await driver.find('.test-config-data').click(); + await getSection(section).click(); + await driver.find('.test-right-select-by').click(); +} + export async function editOrgAcls(): Promise { // To prevent a common flakiness problem, wait for a potentially open modal dialog // to close before attempting to open the account menu. From 39a51e90ec5d8d25c4d030b4d47e81b65a9b7b71 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Wed, 28 Jun 2023 01:36:35 +0000 Subject: [PATCH 05/26] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (823 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 4dcb5f97..3bdc4aee 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -1073,5 +1073,31 @@ "Clear field": "Limpar campo", "Copy": "Copiar", "Cut": "Cortar" + }, + "FormulaAssistant": { + "Capabilities": "Capacidades", + "Formula Help. ": "Ajuda de Fórmula. ", + "Function List": "Lista de funções", + "Grist's AI Assistance": "Assistência de IA do Grist", + "Grist's AI Formula Assistance. ": "Assistência à Fórmula IA do Grist. ", + "Need help? Our AI assistant can help.": "Precisa de ajuda? Nosso assistente de IA pode ajudar.", + "New Chat": "Novo chat", + "Preview": "Prévisualizar", + "Regenerate": "Regenerar", + "Save": "Salvar", + "Tips": "Dicas", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Consulte nossos sites {{helpFunction}} e {{formulaCheat}}, ou visite nosso site {{community}} para obter mais ajuda.", + "Ask the bot.": "Pergunte ao bot.", + "Data": "Dados", + "Formula Cheat Sheet": "Folha de Consulta da Fórmula", + "Community": "Comunidade" + }, + "GridView": { + "Click to insert": "Clique para inserir" + }, + "WelcomeSitePicker": { + "You have access to the following Grist sites.": "Você tem acesso aos seguintes sites do Grist.", + "Welcome back": "Bem-vindo de volta", + "You can always switch sites using the account menu.": "Você sempre pode alternar entre sites usando o menu da conta." } } From b167814d5975eacf95c79bedaf2f3d4a2bd6e49c Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 29 Jun 2023 00:37:18 +0000 Subject: [PATCH 06/26] Translated using Weblate (Spanish) Currently translated at 100.0% (823 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index e82448c0..0c29dbd4 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -1063,5 +1063,31 @@ "WebhookPage": { "Clear Queue": "Borrar la cola", "Webhook Settings": "Ajustes del gancho web" + }, + "FormulaAssistant": { + "Regenerate": "Regenerar", + "Save": "Guardar", + "Preview": "Vista previa", + "Need help? Our AI assistant can help.": "¿Necesitas ayuda? Nuestro asistente de IA puede ayudarle.", + "New Chat": "Nuevo chat", + "Ask the bot.": "Pregúntale al bot.", + "Capabilities": "Capacidades", + "Community": "Comunidad", + "Data": "Datos", + "Formula Cheat Sheet": "Hoja de trucos de fórmulas", + "Formula Help. ": "Ayuda de fórmula. ", + "Function List": "Lista de función", + "Grist's AI Assistance": "Asistencia de IA de Grist", + "Grist's AI Formula Assistance. ": "Asistencia de fórmula de IA de Grist. ", + "Tips": "Consejos", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Consulte nuestras páginas {{helpFunction}} y {{formulaCheat}}, o visite nuestra página {{community}} para obtener más ayuda." + }, + "GridView": { + "Click to insert": "Haga clic para insertar" + }, + "WelcomeSitePicker": { + "Welcome back": "Bienvenido de nuevo", + "You can always switch sites using the account menu.": "Siempre puedes cambiar de sitio utilizando el menú de la cuenta.", + "You have access to the following Grist sites.": "Usted tiene acceso a los siguientes sitios de Grist." } } From 70cec081f0497a3fb4e5b8b1367ac58333f6c008 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Wed, 28 Jun 2023 01:10:43 +0000 Subject: [PATCH 07/26] Translated using Weblate (German) Currently translated at 100.0% (823 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 3d9f3d06..df78bbab 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -1073,5 +1073,31 @@ "WebhookPage": { "Clear Queue": "Warteschlange löschen", "Webhook Settings": "Webhaken Einstellungen" + }, + "FormulaAssistant": { + "Ask the bot.": "Fragen Sie den Bot.", + "Capabilities": "Fähigkeiten", + "Community": "Gemeinschaft", + "Data": "Daten", + "Formula Cheat Sheet": "Formel-Spickzettel", + "Formula Help. ": "Formel-Hilfe. ", + "Function List": "Funktionsliste", + "Grist's AI Assistance": "Grists KI-Unterstützung", + "Grist's AI Formula Assistance. ": "Grists KI-Formelunterstützung. ", + "Need help? Our AI assistant can help.": "Brauchen Sie Hilfe? Unser KI-Assistent kann helfen.", + "New Chat": "Neuer Chat", + "Preview": "Vorschau", + "Regenerate": "Regenerieren", + "Save": "Speichern", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Weitere Informationen finden Sie unter {{helpFunction}} und {{formulaCheat}} oder besuchen Sie unsere {{community}} für weitere Hilfe.", + "Tips": "Tipps" + }, + "GridView": { + "Click to insert": "Zum Einfügen klicken" + }, + "WelcomeSitePicker": { + "Welcome back": "Willkommen zurück", + "You can always switch sites using the account menu.": "Sie können jederzeit über das Kontomenü zwischen den Websites wechseln.", + "You have access to the following Grist sites.": "Sie haben Zugriff auf die folgenden Grist-Seiten." } } From 2d636c5890acfeb3515324561a302b21fbc08b73 Mon Sep 17 00:00:00 2001 From: ssantos Date: Tue, 27 Jun 2023 19:17:49 +0000 Subject: [PATCH 08/26] Translated using Weblate (Portuguese) Currently translated at 99.7% (821 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt/ --- static/locales/pt.client.json | 1039 ++++++++++++++++++++++++++++++++- 1 file changed, 1038 insertions(+), 1 deletion(-) diff --git a/static/locales/pt.client.json b/static/locales/pt.client.json index 0967ef42..e6af54f2 100644 --- a/static/locales/pt.client.json +++ b/static/locales/pt.client.json @@ -1 +1,1038 @@ -{} +{ + "AccountPage": { + "API Key": "Chave API", + "API": "API", + "Account settings": "Configurações de conta", + "Allow signing in to this account with Google": "Permitir o acesso a esta conta com o Google", + "Change Password": "Alterar Palavra-passe", + "Edit": "Editar", + "Email": "E-mail", + "Login Method": "Método de Login", + "Name": "Nome", + "Names only allow letters, numbers and certain special characters": "Nomes só permitem letras, números e certos caracteres especiais", + "Password & Security": "Palavra-passe e Segurança", + "Save": "Gravar", + "Theme": "Tema", + "Two-factor authentication": "Autenticação de dois fatores", + "Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "A autenticação de dois fatores é uma camada extra de segurança para a sua conta Grist projetada para garantir que seja a única pessoa que pode aceder a sua conta, mesmo que alguém saiba a sua palavra-passe.", + "Language": "Idioma" + }, + "ColumnFilterMenu": { + "Others": "Outros", + "Search": "Pesquisar", + "Search values": "Pesquisar valores", + "All": "Todos", + "All Except": "Todos, Exceto", + "All Shown": "Todos Mostrados", + "Filter by Range": "Filtrar por intervalo", + "Future Values": "Valores Futuros", + "No matching values": "Nenhum valor coincidente", + "None": "Nenhum", + "Min": "Mín", + "Max": "Máx", + "Start": "Começo", + "End": "Fim", + "Other Matching": "Outros Coincidentes", + "Other Non-Matching": "Outros Não-Coincidentes", + "Other Values": "Outros Valores" + }, + "CustomSectionConfig": { + "Read selected table": "Ler a tabela selecionada", + " (optional)": " (opcional)", + "Add": "Adicionar", + "Enter Custom URL": "Digite a URL personalizada", + "Full document access": "Acesso total ao documento", + "Learn more about custom widgets": "Saiba mais sobre Widgets personalizados", + "No document access": "Sem acesso ao documento", + "Open configuration": "Abrir configuração", + "Pick a column": "Escolha uma coluna", + "Pick a {{columnType}} column": "Escolha uma coluna {{columnType}}", + "Select Custom Widget": "Selecione o Widget personalizado", + "Widget does not require any permissions.": "O Widget não requer nenhuma permissão.", + "Widget needs to {{read}} the current table.": "O Widget necessita {{read}} a tabela atual.", + "Widget needs {{fullAccess}} to this document.": "O Widget necessita {{fullAccess}} ao documento.", + "{{wrongTypeCount}} non-{{columnType}} columns are not shown_one": "{{wrongTypeCount}} a não-{{columnType}} coluna não é mostrada", + "{{wrongTypeCount}} non-{{columnType}} columns are not shown_other": "{{wrongTypeCount}} as não-{{columnType}} colunas não são mostradas" + }, + "DocHistory": { + "Activity": "Atividade", + "Snapshots": "Instantâneos", + "Beta": "Beta", + "Compare to Current": "Comparar ao atual", + "Compare to Previous": "Comparar ao anterior", + "Open Snapshot": "Abrir Instantâneo", + "Snapshots are unavailable.": "Os instantâneos não estão disponíveis." + }, + "DocMenu": { + "By Date Modified": "Por Data de Modificação", + "Deleted {{at}}": "{{at}} excluído", + "Discover More Templates": "Descubra mais Modelos", + "Document will be moved to Trash.": "O documento será movido para Lixo.", + "Document will be permanently deleted.": "O documento será permanentemente excluído.", + "Documents stay in Trash for 30 days, after which they get deleted permanently.": "Os documentos ficam na Lixo por 30 dias, após os quais são excluídos permanentemente.", + "Edited {{at}}": "{{at}} editado", + "Examples & Templates": "Exemplos & Modelos", + "Examples and Templates": "Exemplos e Modelos", + "Featured": "Destaques", + "Manage Users": "Gerir Utilizadores", + "More Examples and Templates": "Mais Exemplos e Modelos", + "Move": "Mover", + "Move {{name}} to workspace": "Mover {{name}} para a área de trabalho", + "Other Sites": "Outros Sites", + "Permanently Delete \"{{name}}\"?": "Apagar \"{{name}}\" permanentemente?", + "Pin Document": "Fixar documento", + "Pinned Documents": "Documentos Fixados", + "Remove": "Remover", + "Rename": "Renomear", + "Requires edit permissions": "Requer permissões de edição", + "Restore": "Restaurar", + "This service is not available right now": "Este serviço não está disponível no momento", + "To restore this document, restore the workspace first.": "Para restaurar esse documento, restaure a área de trabalho primeiro.", + "Trash": "Lixo", + "Trash is empty.": "A lixo está vazia.", + "Unpin Document": "Desafixar o Documento", + "Workspace not found": "Área de trabalho não encontrada", + "You are on the {{siteName}} site. You also have access to the following sites:": "Está no site {{siteName}}. Também tem acesso aos seguintes sites:", + "(The organization needs a paid plan)": "(A organização precisa de um plano pago)", + "All Documents": "Todos os Documentos", + "By Name": "Por Nome", + "Access Details": "Detalhes de Acesso", + "Current workspace": "Área de trabalho atual", + "Delete": "Apagar", + "Delete Forever": "Apagar para sempre", + "Delete {{name}}": "Apagar {{name}}", + "You are on your personal site. You also have access to the following sites:": "Está na sua página pessoal. Também tem acesso às seguintes páginas:", + "You may delete a workspace forever once it has no documents in it.": "Pode apagar uma área de trabalho para sempre uma vez que ela não contenha documentos." + }, + "GristDoc": { + "Import from file": "Importação de ficheiro", + "Added new linked section to view {{viewName}}": "Adicionada nova secção vinculada para visualizar {{viewName}}}", + "Saved linked section {{title}} in view {{name}}": "Secção vinculada gravada {{title}} em exibição {{name}}" + }, + "FormulaAssistant": { + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Consulte o nosso {{helpFunction}} e {{formulaCheat}} ou visite o nosso {{community}} para obter mais ajuda.", + "Formula Help. ": "Ajuda de fórmulas. ", + "Preview": "Pré-visualização", + "Regenerate": "Regenerar", + "Save": "Gravar", + "Grist's AI Formula Assistance. ": "Assistência de fórmula de IA de Grist. ", + "Ask the bot.": "Pergunte o robô.", + "Data": "Dados", + "New Chat": "Novo chat", + "Formula Cheat Sheet": "Folha de consulta de fórmulas", + "Tips": "Dicas", + "Capabilities": "Capacidades", + "Need help? Our AI assistant can help.": "Precisa de ajuda? O nosso assistente de IA pode ajudar.", + "Community": "Comunidade", + "Function List": "Lista de funções", + "Grist's AI Assistance": "Assistência de IA da Grist" + }, + "WelcomeSitePicker": { + "You can always switch sites using the account menu.": "Sempre pode alternar entre sites através do menu da conta.", + "Welcome back": "Bem-vindo de volta" + }, + "MakeCopyMenu": { + "Cancel": "Cancelar", + "However, it appears to be already identical.": "No entanto, parece já ser idêntico.", + "Include the structure without any of the data.": "Incluir a estrutura sem os dados.", + "It will be overwritten, losing any content not in this document.": "Será substituído, perdendo qualquer conteúdo que não esteja neste documento.", + "Name": "Nome", + "You do not have write access to the selected workspace": "Não tem acesso de gravação na área de trabalho selecionada", + "No destination workspace": "Nenhuma área de trabalho de destino", + "Organization": "Organização", + "Original Has Modifications": "Original Tem Modificações", + "Original Looks Unrelated": "Original Parece não Relacionado", + "Original Looks Identical": "Original Parece Identêntico", + "Overwrite": "Sobreescrever", + "Replacing the original requires editing rights on the original document.": "A substituição do original requer direitos de edição sobre o documento original.", + "Sign up": "Cadastre-se", + "The original version of this document will be updated.": "A versão original deste documento será atualizada.", + "To save your changes, please sign up, then reload this page.": "Para gravar as suas alterações, cadastre-se e recarregue esta página.", + "Update": "Atualizar", + "Update Original": "Atualizar o Original", + "Workspace": "Área de Trabalho", + "You do not have write access to this site": "Não tem acesso de gravação a este site", + "As Template": "Como Modelo", + "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Tenha cuidado, o original tem mudanças que não estão neste documento. Essas mudanças serão sobrescritas.", + "Enter document name": "Digite o nome do documento" + }, + "Pages": { + "Delete": "Apagar", + "Delete data and this page.": "Apagar os dados desta página.", + "The following tables will no longer be visible_one": "A seguinte tabela não será mais visível", + "The following tables will no longer be visible_other": "As seguintes tabelas não serão mais visíveis" + }, + "RightPanel": { + "Theme": "Tema", + "CHART TYPE": "TIPO DE GRÁFICO", + "COLUMN TYPE": "TIPO DE COLUNA", + "CUSTOM": "PERSONALIZADO", + "Change Widget": "Alterar widget", + "Columns_one": "Coluna", + "Columns_other": "Colunas", + "DATA TABLE": "TABELA DE DADOS", + "DATA TABLE NAME": "NOME DA TABELA DE DADOS", + "Data": "Dados", + "Detach": "Separar", + "Edit Data Selection": "Editar Seleção de Dados", + "Fields_one": "Campo", + "Fields_other": "Campos", + "SELECT BY": "SELECIONADO POR", + "GROUPED BY": "AGRUPADO POR", + "SELECTOR FOR": "SELETOR PARA", + "ROW STYLE": "ESTILO DE LINHA", + "Row Style": "Estilo de Linha", + "SOURCE DATA": "DADOS DE ORIGEM", + "Save": "Gravar", + "Select Widget": "Selecionar Widget", + "Series_one": "Séries", + "Series_other": "Séries", + "Sort & Filter": "Ordenar & Filtrar", + "TRANSFORM": "TRANSFORMAR", + "WIDGET TITLE": "TÍTULO DO WIDGET", + "Widget": "Widget", + "You do not have edit access to this document": "Não tem permissão de edição desse documento", + "Add referenced columns": "Adicionar colunas referenciadas" + }, + "ShareMenu": { + "Return to {{termToUse}}": "Retornar ao {{termToUse}}", + "Save Copy": "Gravar Cópia", + "Save Document": "Gravar Documento", + "Send to Google Drive": "Enviar ao Google Drive", + "Show in folder": "Mostrar na pasta", + "Unsaved": "Não gravado", + "Work on a Copy": "Trabalho numa cópia", + "Access Details": "Detalhes de Acesso", + "Back to Current": "Voltar ao Atual", + "Compare to {{termToUse}}": "Comparar ao {{termToUse}}", + "Current Version": "Versão Atual", + "Download": "Descarregar", + "Duplicate Document": "Duplicar o Documento", + "Edit without affecting the original": "Editar sem afetar o original", + "Export CSV": "Exportar CSV", + "Export XLSX": "Exportar XLSX", + "Manage Users": "Gerir Utilizadores", + "Original": "Original", + "Replace {{termToUse}}...": "Substituir {{termToUse}}…" + }, + "SiteSwitcher": { + "Create new team site": "Criar site de equipa", + "Switch Sites": "Alternar sites" + }, + "ViewAsBanner": { + "UnknownUser": "Utilizador desconhecido" + }, + "ColumnInfo": { + "COLUMN LABEL": "RÓTULO DA COLUNA", + "COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA", + "COLUMN ID: ": "ID DA COLUNA: ", + "Cancel": "Cancelar", + "Save": "Gravar" + }, + "FieldBuilder": { + "DATA FROM TABLE": "DADOS DA TABELA", + "Mixed format": "Formato misto", + "Mixed types": "Tipos mistos", + "Apply Formula to Data": "Aplicar fórmula aos dados", + "CELL FORMAT": "FORMATO DA CÉLULA", + "Changing multiple column types": "Alterar vários tipos de colunas", + "Revert field settings for {{colId}} to common": "Reverter configurações de campo da {{colId}} para comum", + "Save field settings for {{colId}} as common": "Gravar configurações de campo da {{colId}} como comum", + "Use separate field settings for {{colId}}": "Use configurações de campo separadas para {{colId}}" + }, + "NumericTextBox": { + "Number Format": "Formato de número", + "Currency": "Moeda", + "Decimals": "Decimais", + "Default currency ({{defaultCurrency}})": "Moeda padrão ({{defaultCurrency}})" + }, + "ACUserManager": { + "Enter email address": "Digite o endereço de e-mail", + "Invite new member": "Convidar novo membro", + "We'll email an invite to {{email}}": "Enviaremos um convite por e-mail para {{email}}" + }, + "AccessRules": { + "Add Column Rule": "Adicionar Regra de Coluna", + "Add Default Rule": "Adicionar Regra Padrão", + "Add Table Rules": "Adicionar Regras de Tabela", + "Add User Attributes": "Adicionar Atibutos de Utilizador", + "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Permitir que todos possam copiar, ver e mexer no documento todo.\nÚtil para exemplos e modelos, mas não para dados sensíveis.", + "Allow everyone to view Access Rules.": "Permitir que todos visualizem as Regras de Acesso.", + "Attribute name": "Nome do atributo", + "Attribute to Look Up": "Atributo para Procurar", + "Checking...": "A verificar…", + "Condition": "Condição", + "Default Rules": "Regras Padrão", + "Delete Table Rules": "Apagar Regras de Tabela", + "Enter Condition": "Insira a condição", + "Everyone": "Todos", + "Everyone Else": "Todos os outros", + "Invalid": "Inválido", + "Lookup Column": "Coluna de pesquisa", + "Lookup Table": "Tabela de Pesquisa", + "Permission to access the document in full when needed": "Permissão para aceder o documento completo quando necessário", + "Permission to view Access Rules": "Permissão para visualizar as Regras de Acesso", + "Permissions": "Permissões", + "Remove column {{- colId }} from {{- tableId }} rules": "Remover a coluna {{- colId }} das regras de {{- tableId }}", + "Remove {{- tableId }} rules": "Remover regras de {{- tableId }}", + "Remove {{- name }} user attribute": "Remover o atributo do utilizador {{- name }}", + "Reset": "Redefinir", + "Rules for table ": "Regras para a tabela ", + "Save": "Gravar", + "Saved": "Gravado", + "Special Rules": "Regras Especiais", + "Type a message...": "Escreva uma mensagem…", + "User Attributes": "Atributos de Utilizador", + "View As": "Ver como", + "Seed rules": "Regras de propagação", + "When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.", + "Permission to edit document structure": "Permissão para editar a estrutura do documento" + }, + "ChartView": { + "Toggle chart aggregation": "Alternar a agregação de gráficos", + "selected new group data columns": "novas colunas de dados de grupo selecionadas", + "Create separate series for each value of the selected column.": "Crie séries separadas para cada valor da coluna selecionada.", + "Each Y series is followed by a series for the length of error bars.": "Cada série Y é seguida por uma série para o comprimento das barras de erro.", + "Each Y series is followed by two series, for top and bottom error bars.": "Cada série Y é seguida por duas séries, para as barras de erro superior e inferior.", + "Pick a column": "Escolha uma coluna" + }, + "CodeEditorPanel": { + "Access denied": "Acesso negado", + "Code View is available only when you have full document access.": "A Vista de Código só está disponível quando tem acesso total aos documentos." + }, + "NotifyUI": { + "Ask for help": "Peça ajuda", + "Cannot find personal site, sorry!": "Não encontro site pessoal, desculpe!", + "Give feedback": "Dar feedback", + "Go to your free personal site": "Acede o seu site pessoal gratuito", + "No notifications": "Nenhuma notificação", + "Notifications": "Notificações", + "Renew": "Renovar", + "Report a problem": "Reportar um problema", + "Upgrade Plan": "Atualizar o Plano" + }, + "OpenVideoTour": { + "Video Tour": "Tour de Vídeo", + "Grist Video Tour": "Tour de Vídeo Grist", + "YouTube video player": "Reprodutor de vídeo YouTube" + }, + "OnBoardingPopups": { + "Finish": "Terminar", + "Next": "Próximo" + }, + "PageWidgetPicker": { + "Add to Page": "Adicionar à Página", + "Building {{- label}} widget": "Construír o {{- label}} widget", + "Group by": "Agrupar por", + "Select Data": "Selecionar dados", + "Select Widget": "Selcione o Widget" + }, + "PermissionsWidget": { + "Allow All": "Permitir Todos", + "Deny All": "Recusar Todos", + "Read Only": "Somente leitura" + }, + "PluginScreen": { + "Import failed: ": "Falha na importação: " + }, + "RecordLayout": { + "Updating record layout.": "Atualizar o layout dos registos." + }, + "RecordLayoutEditor": { + "Add Field": "Adicionar Campo", + "Create New Field": "Criar um Novo Campo", + "Show field {{- label}}": "Mostrar campo {{- label}}", + "Save Layout": "Guardar Layout", + "Cancel": "Cancelar" + }, + "RefSelect": { + "Add Column": "Adicionar Coluna", + "No columns to add": "Não há colunas para adicionar" + }, + "RowContextMenu": { + "Copy anchor link": "Copiar a ligação de ancoragem", + "Delete": "Apagar", + "Duplicate rows_one": "Duplicar linha", + "Duplicate rows_other": "Duplicar linhas", + "Insert row": "Inserir linha", + "Insert row above": "Inserir linha acima", + "Insert row below": "Inserir linha abaixo" + }, + "SelectionSummary": { + "Copied to clipboard": "Copiado para a área de transferência" + }, + "SortConfig": { + "Add Column": "Adicionar Coluna", + "Empty values last": "Valores vazios por último", + "Natural sort": "Classificação natural", + "Update Data": "Atualizar Dados", + "Use choice position": "Usar posição de escolha", + "Search Columns": "Procurar colunas" + }, + "SortFilterConfig": { + "Filter": "FILTRAR", + "Revert": "Reverter", + "Save": "Gravar", + "Sort": "ORDENAR", + "Update Sort & Filter settings": "Atualizar configurações de Classificação e Filtro" + }, + "ThemeConfig": { + "Appearance ": "Aparência ", + "Switch appearance automatically to match system": "Alternar a aparência automaticamente para corresponder ao sistema" + }, + "Tools": { + "Access Rules": "Regras de Acesso", + "Code View": "Vista do Código", + "Delete": "Apagar", + "Delete document tour?": "Apagar tour do documento?", + "Document History": "Histórico do Documento", + "How-to Tutorial": "Tutorial de Como Fazer", + "Raw Data": "Dados Primários", + "Return to viewing as yourself": "Voltar a ver como você mesmo", + "TOOLS": "FERRAMENTAS", + "Tour of this Document": "Tour desse Documento", + "Validate Data": "Validar dados", + "Settings": "Configurações" + }, + "TopBar": { + "Manage Team": "Gerir Equipa" + }, + "TriggerFormulas": { + "Cancel": "Cancelar", + "Close": "Fechar", + "Current field ": "Campo atual ", + "OK": "OK", + "Any field": "Qualquer campo", + "Apply on changes to:": "Aplicar em alterações para:", + "Apply on record changes": "Aplicar em alterações de registo", + "Apply to new records": "Aplicar a novos registos" + }, + "TypeTransformation": { + "Apply": "Aplicar", + "Cancel": "Cancelar", + "Preview": "Prévisualizar", + "Revise": "Revisar", + "Update formula (Shift+Enter)": "Atualizar a fórmula (Shift+Enter)" + }, + "UserManagerModel": { + "Editor": "Editor", + "In Full": "Na íntegra", + "No Default Access": "Sem Acesso Padrão", + "None": "Nenhum", + "Owner": "Proprietário", + "View & Edit": "Ver & Editar", + "View Only": "Somente Ver", + "Viewer": "Observador" + }, + "ValidationPanel": { + "Rule {{length}}": "Regra {{length}}", + "Update formula (Shift+Enter)": "Atualizar a fórmula (Shift+Enter)" + }, + "ViewConfigTab": { + "Advanced settings": "Configurações avançadas", + "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "As tabelas grandes podem ser marcadas como \"sob-necessidade\" para evitar o seu carregamento no mecanismo de dados.", + "Blocks": "Blocos", + "Compact": "Compactar", + "Edit Card Layout": "Editar Layout do Cartão", + "Form": "Formulário", + "Make On-Demand": "Fazer Sob-Necessidade", + "Plugin: ": "Plugin: ", + "Section: ": "Secção: ", + "Unmark On-Demand": "Desmarcar Sob-Necessidade" + }, + "FieldConfig": { + "Make into data column": "Transformar em coluna de dados", + "Mixed Behavior": "Comportamento Misto", + "Set formula": "Definir fórmula", + "Set trigger formula": "Definir fórmula de disparo", + "TRIGGER FORMULA": "FÓRMULA DE DISPARO", + "DESCRIPTION": "DESCRIÇÃO", + "COLUMN BEHAVIOR": "COMPORTAMENTO DE COLUNA", + "COLUMN LABEL AND ID": "RÓTULO E IDENTIFICAÇÃO DA COLUNA", + "Clear and make into formula": "Limpar e transformar em fórmula", + "Clear and reset": "Limpar e redefinir", + "Column options are limited in summary tables.": "As opções de coluna são limitadas nas tabelas de resumo.", + "Convert column to data": "Converter coluna para dados", + "Convert to trigger formula": "Converter em fórmula de disparo", + "Data Columns_one": "Coluna de Dados", + "Data Columns_other": "Colunas de Dados", + "Empty Columns_one": "Coluna vazia", + "Empty Columns_other": "Colunas Vazias", + "Enter formula": "Insira a fórmula", + "Formula Columns_one": "Coluna de Fórmula", + "Formula Columns_other": "Colunas de Fórmula" + }, + "FieldMenus": { + "Revert to common settings": "Reverter para configurações comuns", + "Save as common settings": "Gravar como configuraçes comuns", + "Use separate settings": "Use configurações separadas", + "Using common settings": "Utilizar configurações comuns", + "Using separate settings": "Utilizar configurações separadas" + }, + "FilterConfig": { + "Add Column": "Adicionar Coluna" + }, + "FilterBar": { + "SearchColumns": "Procurar colunas", + "Search Columns": "Procurar colunas" + }, + "GridOptions": { + "Grid Options": "Opções de Grade", + "Horizontal Gridlines": "Linhas de Grade Horizontais", + "Vertical Gridlines": "Linhas de Grade Verticais", + "Zebra Stripes": "Listras de Zebra" + }, + "GridViewMenus": { + "Add Column": "Adicionar Coluna", + "Add to sort": "Adicionar à classificação", + "Clear values": "Limpar valores", + "Column Options": "Opções de Coluna", + "Convert formula to data": "Converter fórmula em dados", + "Delete {{count}} columns_one": "Apagar coluna", + "Delete {{count}} columns_other": "Apagar {{count}} colunas", + "Filter Data": "Filtrar Dados", + "Freeze {{count}} more columns_one": "Congelar mais uma coluna", + "Freeze {{count}} more columns_other": "Congelar {{count}} colunas mais", + "Freeze {{count}} columns_one": "Congelar esta coluna", + "Freeze {{count}} columns_other": "Congelar {{count}} colunas", + "Hide {{count}} columns_one": "Ocultar coluna", + "Hide {{count}} columns_other": "Ocultar {{count}} colunas", + "Insert column to the {{to}}": "Inserir coluna para a {{to}}", + "More sort options ...": "Mais opções de ordenação…", + "Rename column": "Renomear coluna", + "Reset {{count}} columns_one": "Reinicializar coluna", + "Reset {{count}} columns_other": "Reinicializar {{count}} colunas", + "Reset {{count}} entire columns_one": "Reinicializar toda a coluna", + "Reset {{count}} entire columns_other": "Reinicializar {{count}} colunas inteiras", + "Sort": "Ordenar", + "Show column {{- label}}": "Mostrar coluna {{- label}}", + "Sorted (#{{count}})_one": "Ordenado (#{{count}})", + "Sorted (#{{count}})_other": "Ordenado (#{{count}})", + "Unfreeze all columns": "Descongelar todas as colunas", + "Unfreeze {{count}} columns_one": "Descongelar esta coluna", + "Unfreeze {{count}} columns_other": "Descongelar {{count}} colunas", + "Insert column to the left": "Inserir coluna à esquerda", + "Insert column to the right": "Inserir coluna à direita" + }, + "HomeIntro": { + "Any documents created in this site will appear here.": "Qualquer documento criado neste site aparecerá aqui.", + "Browse Templates": "Explore os Modelos", + "Create Empty Document": "Criar um Documento Vazio", + "Get started by creating your first Grist document.": "Comece a criar o seu primeiro documento Grist.", + "Get started by exploring templates, or creating your first Grist document.": "Comece a explorar os modelos, ou criar o seu primeiro documento Grist.", + "Get started by inviting your team and creating your first Grist document.": "Comece a convidar a sua equipa e criar o seu primeiro documento Grist.", + "Help Center": "Centro de Ajuda", + "Import Document": "Importar Documento", + "Interested in using Grist outside of your team? Visit your free ": "Interessado em usar Grist além da sua equipa? Visite gratuitamente o seu ", + "Invite Team Members": "Convide membros da equipa", + "Sign up": "Cadastre-se", + "Sprouts Program": "Programa Brotos", + "This workspace is empty.": "Essa área de trabalho está vazia.", + "Visit our {{link}} to learn more.": "Visite o nosso {{link}} para saber mais.", + "Welcome to Grist!": "Bem-vindo ao Grist!", + "Welcome to Grist, {{name}}!": "Bem-vindo ao Grist, {{name}}!", + "Welcome to {{orgName}}": "Bem-vindo ao {{orgName}}", + "You have read-only access to this site. Currently there are no documents.": "Só tem acesso de leitura a este site. Atualmente não há documentos.", + "personal site": "Site pessoal", + "{{signUp}} to save your work. ": "{{signUp}} para gravar o seu trabalho. ", + "Welcome to Grist, {{- name}}!": "Bem-vindo ao Grist, {{-name}}!", + "Welcome to {{- orgName}}": "Bem-vindo a {{-orgName}}" + }, + "GristTooltips": { + "Apply conditional formatting to rows based on formulas.": "Aplicar formatação condicional em linhas com base em fórmulas.", + "Updates every 5 minutes.": "Atualiza a cada 5 minutos.", + "Apply conditional formatting to cells in this column when formula conditions are met.": "Aplicar formatação condicional às células nesta coluna quando as condições da fórmula forem atendidas.", + "Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "As células numa coluna de referência sempre identificam um registo {{entire}} nessa tabela, mas pode selecionar qual coluna desse registo deve ser mostrada.", + "Click on “Open row styles” to apply conditional formatting to rows.": "Clique em \"Abrir estilos de linhas\" para aplicar a formatação condicional às linhas.", + "Click the Add New button to create new documents or workspaces, or import data.": "Clique no botão Adicionar Novo para criar documentos ou espaços de trabalho ou importar dados.", + "Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.": "Clicar {{EyeHideIcon}} em cada célula esconde o campo desta visualização sem apagá-lo.", + "Editing Card Layout": "A editar o layout do cartão", + "Formulas that trigger in certain cases, and store the calculated value as data.": "Fórmulas que acionam em certos casos e armazenam o valor calculado como dados.", + "Learn more.": "Saiba mais.", + "Link your new widget to an existing widget on this page.": "Vincule o seu novo widget a um widget existente nesta página.", + "Linking Widgets": "A vincular widgets", + "Nested Filtering": "Filtragem aninhada", + "Only those rows will appear which match all of the filters.": "Somente serão exibidas as linhas que correspondem a todos os filtros.", + "Pinned filters are displayed as buttons above the widget.": "Os filtros fixados são exibidos como botões acima do widget.", + "Pinning Filters": "A fixar filtros", + "Raw Data page": "Página de dados brutos", + "Rearrange the fields in your card by dragging and resizing cells.": "Organize os campos no seu cartão arrastando e redimensionando células.", + "Reference Columns": "Colunas de referência", + "Reference columns are the key to {{relational}} data in Grist.": "As colunas de referência são a chave para os dados {{relational}} no Grist.", + "Select the table containing the data to show.": "Selecione a tabela que contém os dados a serem exibidos.", + "Select the table to link to.": "Selecione a tabela à qual se vincular.", + "Selecting Data": "A selecionar dados", + "The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.": "A página de dados brutos lista todas as tabelas de dados no seu documento, incluindo tabelas de resumo e tabelas não incluídas nos layouts de página.", + "The total size of all data in this document, excluding attachments.": "O tamanho total de todos os dados deste documento, excluindo os anexos.", + "They allow for one record to point (or refer) to another.": "Eles permitem que um registo aponte (ou se refira) a outro.", + "This is the secret to Grist's dynamic and productive layouts.": "Este é o segredo dos layouts dinâmicos e produtivos do Grist.", + "Try out changes in a copy, then decide whether to replace the original with your edits.": "Experimente mudanças numa cópia, depois decida se deseja substituir o original pelas suas edições.", + "Unpin to hide the the button while keeping the filter.": "Desfixe para ocultar o botão enquanto mantém o filtro.", + "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Use o ícone \\u{1D6BA} para criar tabelas de resumo (ou dinâmicas) para totais ou subtotais.", + "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Útil para armazenar o carimbo da hora ou autor de um novo registo, limpeza de dados e muito mais.", + "You can filter by more than one column.": "Pode filtrar por mais que uma coluna.", + "entire": "inteiro", + "relational": "relacionais", + "Access Rules": "Regras de Acesso", + "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "As regras de acesso lhe dão o poder de criar regras diferenciadas para determinar quem pode ver ou editar quais partes do seu documento.", + "Add New": "Adicionar Novo", + "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Use o ícone 𝚺 para criar tabelas resumidas (ou dinâmicas), para totais ou subtotais.", + "Anchor Links": "Ligações de âncora", + "Custom Widgets": "Widgets personalizados" + }, + "WelcomeQuestions": { + "IT & Technology": "TI e Tecnologia", + "Marketing": "Publicidade", + "Media Production": "Produção de Mídia", + "Other": "Outros", + "Product Development": "Desenvolvimento de Produto", + "Research": "Investigação", + "Sales": "Vendas", + "Type here": "Digite aqui", + "Welcome to Grist!": "Bem-vindo ao Grist!", + "What brings you to Grist? Please help us serve you better.": "O que te traz ao Grist? Por favor, ajude-nos a atendê-lo melhor.", + "Education": "Educação", + "Finance & Accounting": "Finanças e Contabilidade", + "HR & Management": "RH e Gestão" + }, + "WidgetTitle": { + "Cancel": "Cancelar", + "DATA TABLE NAME": "NOME DA TABELA DE DADOS", + "Override widget title": "Substituir o título do Widget", + "Provide a table name": "Forneça um nome de tabela", + "Save": "Gravar", + "WIDGET TITLE": "TÍTULO DO WIDGET", + "WIDGET DESCRIPTION": "DESCRIÇÃO DO WIDGET" + }, + "breadcrumbs": { + "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Pode fazer edições, mas elas criarão uma nova cópia e\nnão afetarão o documento original.", + "override": "sobreescrever", + "fiddle": "mexer", + "recovery mode": "modo de recuperação", + "snapshot": "instantâneo", + "unsaved": "não-gravado" + }, + "duplicatePage": { + "Duplicate page {{pageName}}": "Duplicar página {{pageName}}", + "Note that this does not copy data, but creates another view of the same data.": "Observe que isto não copia dados, mas cria uma outra visão dos mesmos dados." + }, + "errorPages": { + "Access denied{{suffix}}": "Acesso negado{{suffix}}", + "Add account": "Adicionar conta", + "Contact support": "Entre em contato com o suporte", + "Error{{suffix}}": "Erro {{suffix}}", + "Go to main page": "Ir para a página principal", + "Page not found{{suffix}}": "Página não encontrada {{suffix}}", + "Sign in": "Entrar", + "Sign in again": "Iniciar sessão novamente", + "Sign in to access this organization's documents.": "Faça o login para aceder os documentos desta organização.", + "Signed out{{suffix}}": "Sessão finalizada{{suffix}}", + "Something went wrong": "Algo deu errado", + "The requested page could not be found.{{separator}}Please check the URL and try again.": "A página solicitada não pôde ser encontrada.{{separator}}Por favor, verifique a URL e tente novamente.", + "There was an error: {{message}}": "Houve um erro: {{message}}", + "There was an unknown error.": "Houve um erro desconhecido.", + "You are now signed out.": "Agora está fora da sessão.", + "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "Está inscrito como {{email}}. Pode entrar com uma conta diferente, ou pedir acesso a um administrador.", + "You do not have access to this organization's documents.": "Não tem acesso aos documentos desta organização." + }, + "CellStyle": { + "Cell Style": "Estilo de célula", + "Default cell style": "Estilo de célula padrão", + "Mixed style": "Estilo misto", + "Open row styles": "Estilos de linha aberta", + "CELL STYLE": "ESTILO DE CÉLULA" + }, + "ChoiceTextBox": { + "CHOICES": "ESCOLHAS" + }, + "ColumnEditor": { + "COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA", + "COLUMN LABEL": "RÓTULO DA COLUNA" + }, + "ConditionalStyle": { + "Add another rule": "Adicionar outra regra", + "Add conditional style": "Adicionar estilo condicional", + "Error in style rule": "Erro na regra de estilo", + "Row Style": "Estilo de Linha", + "Rule must return True or False": "A regra deve retornar Verdadeiro ou Falso" + }, + "CurrencyPicker": { + "Invalid currency": "Moeda inválida" + }, + "DiscussionEditor": { + "Cancel": "Cancelar", + "Comment": "Comentário", + "Edit": "Editar", + "Marked as resolved": "Marcado como resolvido", + "Only current page": "Somente a página atual", + "Only my threads": "Somente os meus tópicos", + "Open": "Abrir", + "Remove": "Remover", + "Reply": "Responder", + "Resolve": "Resolver", + "Reply to a comment": "Responder a um comentário", + "Show resolved comments": "Mostrar comentários resolvidos", + "Showing last {{nb}} comments": "Mostrar os últimos {{nb}} comentários", + "Started discussion": "Discussão iniciada", + "Write a comment": "Escreva um comentário", + "Save": "Gravar" + }, + "EditorTooltip": { + "Convert column to formula": "Converter coluna em fórmula" + }, + "WelcomeTour": { + "convert to card view, select data, and more.": "converta para a visualização de cartão, selecione dados e muito mais.", + "creator panel": "painel do criador", + "template library": "biblioteca de modelos", + "Add New": "Adicionar Novo", + "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Procure o nosso {{templateLibrary}} para descobrir o que é possível e se inspirar.", + "Building up": "A construir", + "Configuring your document": "A configurar o seu documento", + "Customizing columns": "A personalizar colunas", + "Double-click or hit {{enter}} on a cell to edit it. ": "Clique duas vezes ou pressione {{enter}} numa célula para editá-la. ", + "Editing Data": "A editar dados", + "Enter": "Entra", + "Flying higher": "A voar mais alto", + "Help Center": "Centro de Ajuda", + "Make it relational! Use the {{ref}} type to link tables. ": "Faça-o relacional! Use o tipo {{ref}} para vincular tabelas. ", + "Reference": "Referência", + "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Defina opções de formatação, fórmulas ou tipos de coluna, como datas, escolhas ou anexos. ", + "Share": "Partilhar", + "Sharing": "A partilhar", + "Start with {{equal}} to enter a formula.": "Comece com {{equal}} para inserir uma fórmula.", + "Toggle the {{creatorPanel}} to format columns, ": "Alternar o {{creatorPanel}} para formatar colunas, ", + "Use the Share button ({{share}}) to share the document or export data.": "Use o botão Partilhar ({{share}}) para partilhar o documento ou exportar dados.", + "Use {{addNew}} to add widgets, pages, or import more data. ": "Use {{addNew}} para adicionar widgets, páginas ou importar mais dados. ", + "Use {{helpCenter}} for documentation or questions.": "Use {{helpCenter}} para documentação ou perguntas.", + "Welcome to Grist!": "Bem-vindo ao Grist!" + }, + "LanguageMenu": { + "Language": "Idioma" + }, + "GridView": { + "Click to insert": "Clique para inserir" + }, + "AccountWidget": { + "Access Details": "Detalhes de Acesso", + "Accounts": "Contas", + "Add Account": "Adicionar Conta", + "Document Settings": "Configurações do documento", + "Manage Team": "Gerir Equipa", + "Pricing": "Preços", + "Profile Settings": "Configurações de Perfil", + "Sign Out": "Sair", + "Sign in": "Entrar", + "Switch Accounts": "Alternar Contas", + "Toggle Mobile Mode": "Alternar Modo Móvel" + }, + "ViewAsDropdown": { + "View As": "Ver como", + "Users from table": "Utilizadores da tabela", + "Example Users": "Utilizadores de exemplo" + }, + "ActionLog": { + "Action Log failed to load": "Falha ao carregar o Log de Ações", + "Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "A Coluna {{colId}} foi posteriormente removida em ação #{{action.actionNum}}", + "Table {{tableId}} was subsequently removed in action #{{actionNum}}": "A Tabela {{tableId}} foi posteriormente removida em ação #{{actionNum}}", + "This row was subsequently removed in action {{action.actionNum}}": "Essa linha foi posteriormente removida em ação {{action.actionNum}}" + }, + "AddNewButton": { + "Add New": "Adicionar Novo" + }, + "ApiKey": { + "By generating an API key, you will be able to make API calls for your own account.": "Ao gerar uma chave API, será capaz de fazer chamadas API para a sua própria conta.", + "Click to show": "Clique para mostrar", + "Create": "Criar", + "Remove": "Remover", + "Remove API Key": "Remover a Chave API", + "This API key can be used to access this account anonymously via the API.": "Esta chave API pode ser usada para aceder esta conta anonimamente através da API.", + "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "Esta chave API pode ser usada para aceder a sua conta através da API. Não partilhe a sua chave API com ninguém.", + "You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?": "Está prestes a apagar uma chave API. Isto fará com que todas as solicitações futuras usando esta chave API sejam rejeitadas. Realmente quer apagar?" + }, + "App": { + "Description": "Descrição", + "Key": "Chave", + "Memory Error": "Erro de Memória", + "Translators: please translate this only when your language is ready to be offered to users": "Tradutores: por favor, traduzam isto apenas quando o seu idioma estiver pronto para ser oferecido aos utilizadores" + }, + "AppHeader": { + "Home Page": "Página inicial", + "Legacy": "Legado", + "Personal Site": "Site pessoal", + "Team Site": "Site da Equipa" + }, + "AppModel": { + "This team site is suspended. Documents can be read, but not modified.": "Este site da equipa está suspenso. Os documentos podem ser lidos, mas não modificados." + }, + "CellContextMenu": { + "Clear cell": "Limpar célula", + "Clear values": "Limpar valores", + "Copy anchor link": "Copiar a ligação de ancoragem", + "Delete {{count}} columns_one": "Apagar coluna", + "Delete {{count}} columns_other": "Apagar {{count}} colunas", + "Delete {{count}} rows_one": "Apagar linha", + "Delete {{count}} rows_other": "Apagar {{count}} linhas", + "Duplicate rows_one": "Duplicar linha", + "Duplicate rows_other": "Duplicar linhas", + "Filter by this value": "Filtre por este valor", + "Insert column to the left": "Inserir coluna à esquerda", + "Insert column to the right": "Inserir coluna à direita", + "Insert row": "Inserir linha", + "Insert row above": "Inserir linha acima", + "Insert row below": "Inserir linha abaixo", + "Reset {{count}} columns_one": "Reinicializar coluna", + "Reset {{count}} columns_other": "Reinicializar {{count}} colunas", + "Reset {{count}} entire columns_one": "Reinicializar toda a coluna", + "Reset {{count}} entire columns_other": "Reinicializar {{count}} colunas inteiras", + "Comment": "Comentário", + "Copy": "Copiar", + "Cut": "Cortar", + "Paste": "Colar" + }, + "ColorSelect": { + "Apply": "Aplicar", + "Cancel": "Cancelar", + "Default cell style": "Estilo de célula padrão" + }, + "DataTables": { + "Click to copy": "Clique para copiar", + "Delete {{formattedTableName}} data, and remove it from all pages?": "Apagar os dados da {{formattedTableName}} e removê-la de todas as páginas?", + "Duplicate Table": "Duplicar a Tabela", + "Raw Data Tables": "Tabelas de Dados Primários", + "Table ID copied to clipboard": "ID da Tabela copiada para a área de transferência", + "You do not have edit access to this document": "Não tem permissão de edição desse documento" + }, + "DocPageModel": { + "Add Empty Table": "Adicionar Tabela Vazia", + "Add Page": "Adicionar Página", + "Add Widget to Page": "Adicionar Widget à Página", + "Document owners can attempt to recover the document. [{{error}}]": "Proprietários do documento podem tentar recuperar o documento. [{{error}}]", + "Enter recovery mode": "Entrar em modo de recuperação", + "Error accessing document": "Erro ao aceder o documento", + "Reload": "Recarregar", + "Sorry, access to this document has been denied. [{{error}}]": "Desculpe, o acesso a esse documento foi negado. [{{error}}]", + "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]": "Pode tentar recarregar o documento ou usar o modo de recuperação. O modo de recuperação abre o documento para ser totalmente acessível aos proprietários e inacessível a outras pessoas. Ele também desativa as fórmulas. [{{error}}]", + "You do not have edit access to this document": "Não tem permissão de edição desse documento" + }, + "DocTour": { + "Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.": "Não é possível construir um Tour a partir dos dados contidos neste documento. Certifique-se de que haja uma tabela chamada GristDocTour com colunas Title, Body, Placement e Location.", + "No valid document tour": "Tour de documento inválido" + }, + "DocumentSettings": { + "Currency:": "Moeda:", + "Document Settings": "Configurações do documento", + "Engine (experimental {{span}} change at own risk):": "Motor (experimental {{span}} mudança por conta e risco próprios):", + "Local currency ({{currency}})": "Moeda local ({{currency}})", + "Locale:": "Localização:", + "Save": "Gravar", + "Save and Reload": "Gravar e Recarregar", + "This document's ID (for API use):": "O ID deste documento (para uso em API):", + "Time Zone:": "Fuso horário:", + "API": "API", + "Document ID copied to clipboard": "ID do documento copiado para a área de transferência", + "Ok": "OK", + "Manage Webhooks": "Gerir ganchos web", + "Webhooks": "Ganchos Web" + }, + "DocumentUsage": { + "Attachments Size": "Tamanho dos Anexos", + "Contact the site owner to upgrade the plan to raise limits.": "Entre em contato com o proprietário do site para atualizar o plano para aumentar os limites.", + "Data Size": "Tamanho dos Dados", + "For higher limits, ": "Para limites maiores, ", + "Rows": "Linhas", + "Usage": "Uso", + "Usage statistics are only available to users with full access to the document data.": "As estatísticas de uso só estão disponíveis para utilizadores com acesso total aos dados do documento.", + "start your 30-day free trial of the Pro plan.": "comece a sua avaliação gratuita de 30 dias do plano Pro." + }, + "Drafts": { + "Restore last edit": "Restaurar a última edição", + "Undo discard": "Desfazer descarte" + }, + "DuplicateTable": { + "Copy all data in addition to the table structure.": "Copiar todos os dados, além da estrutura da tabela.", + "Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}": "Em vez de dplicar tabelas, geralmente é melhor segmentar os dados usando vistas vinculadas. {{link}}", + "Name for new table": "Nome para a nova tabela", + "Only the document default access rules will apply to the copy.": "Somente as regras de acesso padrão do documento serão aplicadas à cópia." + }, + "ExampleInfo": { + "Afterschool Program": "Programa Pós-Escolar", + "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Confira o nosso tutorial relacionado para saber como vincular dados e criar leiautes de alta produtividade.", + "Check out our related tutorial for how to model business data, use formulas, and manage complexity.": "Consulte o nosso tutorial relacionado para saber como modelar dados corporativos, usar fórmulas e gerir a complexidade.", + "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Confira o nosso tutorial relacionado para aprender como criar tabelas e gráficos resumidos e para vincular os gráficos dinamicamente.", + "Investment Research": "Pesquisa de Investimento", + "Lightweight CRM": "CRM Simples (Gestão de Relações com o Cliente)", + "Tutorial: Analyze & Visualize": "Tutorial: Analisar e Visualizar", + "Tutorial: Create a CRM": "Tutorial: Criar um CRM", + "Tutorial: Manage Business Data": "Tutorial: Gerir dados corporativos", + "Welcome to the Afterschool Program template": "Bem vindo ao modelo do Programa Pós-Escolar", + "Welcome to the Investment Research template": "Bem vindo ao modelo de Pesquisa de Investimento", + "Welcome to the Lightweight CRM template": "Bem vindo ao modelo de CRM Simples (gestão de relações com o cliente)" + }, + "HomeLeftPane": { + "Access Details": "Detalhes de Acesso", + "All Documents": "Todos os Documentos", + "Create Empty Document": "Criar um Documento Vazio", + "Create Workspace": "Criar Área de Trabalho", + "Delete": "Apagar", + "Delete {{workspace}} and all included documents?": "Apagar {{workspace}} e todos os documentos inclusos?", + "Examples & Templates": "Modelos", + "Import Document": "Importar Documento", + "Manage Users": "Gerir Utilizadores", + "Rename": "Renomear", + "Trash": "Lixo", + "Workspace will be moved to Trash.": "A Área de Trabalho será movida para Lixo.", + "Workspaces": "Áreas de Trabalho", + "Tutorial": "Tutorial" + }, + "Importer": { + "Merge rows that match these fields:": "Mesclar linhas que correspondem a estes campos:", + "Select fields to match on": "Selecione os campos a serem correspondidos em", + "Update existing records": "Atualizar os registos existentes" + }, + "LeftPanelCommon": { + "Help Center": "Centro de Ajuda" + }, + "ViewLayoutMenu": { + "Advanced Sort & Filter": "Ordenar & filtrar avançados", + "Copy anchor link": "Copiar a ligação de ancoragem", + "Data selection": "Seleção de dados", + "Delete record": "Apagar registo", + "Delete widget": "Apagar Widget", + "Download as CSV": "Descarregar como CSV", + "Download as XLSX": "Descarregar como XLSX", + "Edit Card Layout": "Editar Layout de cartão", + "Open configuration": "Abrir configuração", + "Print widget": "Imprimir Widget", + "Show raw data": "Mostrar dados primários", + "Widget options": "Opções do Widget", + "Add to page": "Adicionar à página", + "Collapse widget": "Colapsar widget" + }, + "ViewSectionMenu": { + "(customized)": "(personalizado)", + "(empty)": "(vazio)", + "(modified)": "(modificado)", + "Custom options": "Opções personalizadas", + "FILTER": "FILTRAR", + "Revert": "Reverter", + "SORT": "ORDENAR", + "Save": "Gravar", + "Update Sort&Filter settings": "Atualizar configurações de Ordenar e Filtrar" + }, + "VisibleFieldsConfig": { + "Cannot drop items into Hidden Fields": "Não é possível lançar itens em Campos Ocultos", + "Clear": "Limpar", + "Hidden Fields cannot be reordered": "Campos ocultos não podem ser reordenados", + "Select All": "Selecionar Todos", + "Visible {{label}}": "{{label}} visível", + "Hide {{label}}": "Ocultar {{label}}", + "Hidden {{label}}": "{{label}} escondido", + "Show {{label}}": "Mostrar {{label}}" + }, + "menus": { + "* Workspaces are available on team plans. ": "* As áreas de trabalho estão disponíveis nos planos de equipa. ", + "Select fields": "Selecionar campos", + "Upgrade now": "Atualizar agora", + "Any": "Qualquer", + "Numeric": "Numérico", + "Text": "Texto", + "Integer": "Inteiro", + "Toggle": "Alternar", + "Date": "Data", + "DateTime": "DataHora", + "Choice": "Opção", + "Choice List": "Lista de opções", + "Reference": "Referência", + "Reference List": "Lista de Referências", + "Attachment": "Anexo" + }, + "modals": { + "Cancel": "Cancelar", + "Ok": "OK", + "Save": "Gravar" + }, + "pages": { + "Duplicate Page": "Duplicar a Página", + "Remove": "Remover", + "Rename": "Renomear", + "You do not have edit access to this document": "Não tem permissão de edição desse documento" + }, + "search": { + "Find Next ": "Encontrar Próximo ", + "Find Previous ": "Encontrar Anterior ", + "No results": "Sem resultados", + "Search in document": "Procurar no documento" + }, + "sendToDrive": { + "Sending file to Google Drive": "A enviar ficheiro ao Google Drive" + }, + "NTextBox": { + "false": "falso", + "true": "verdadeiro" + }, + "ACLUsers": { + "Example Users": "Utilizadores de exemplo", + "Users from table": "Utilizadores da tabela", + "View As": "Ver como" + }, + "TypeTransform": { + "Apply": "Aplicar", + "Cancel": "Cancelar", + "Preview": "Pré-visualização", + "Revise": "Revisar", + "Update formula (Shift+Enter)": "Atualizar a fórmula (Shift+Enter)" + }, + "FieldEditor": { + "It should be impossible to save a plain data value into a formula column": "Deveria ser impossível de gravar um valor de dados simples numa coluna de fórmula", + "Unable to finish saving edited cell": "Não é possível concluir gravar a célula editada" + }, + "FormulaEditor": { + "Column or field is required": "Coluna ou campo é obrigatório", + "Error in the cell": "Erro na célula", + "Errors in all {{numErrors}} cells": "Erro em todas as {{numErrors}} células", + "Errors in {{numErrors}} of {{numCells}} cells": "Erros em {{numErrors}} de {{numCells}} células", + "editingFormula is required": "ediçãoFórmula é obrigatório" + }, + "HyperLinkEditor": { + "[link label] url": "[rótulo da ligação] URL" + }, + "Reference": { + "CELL FORMAT": "FORMATO DA CÉLULA", + "Row ID": "ID da linha", + "SHOW COLUMN": "MOSTRAR COLUNA" + }, + "DescriptionConfig": { + "DESCRIPTION": "DESCRIÇÃO" + }, + "PagePanels": { + "Close Creator Panel": "Fechar Painel do Criador", + "Open Creator Panel": "Abrir o Painel do Criador" + }, + "ColumnTitle": { + "Add description": "Adicionar descrição", + "COLUMN ID: ": "ID DA COLUNA: ", + "Cancel": "Cancelar", + "Column ID copied to clipboard": "ID da coluna copiada para a área de transferência", + "Column description": "Descrição da coluna", + "Column label": "Rótulo da coluna", + "Provide a column label": "Forneça um rótulo de coluna", + "Save": "Gravar", + "Close": "Fechar" + }, + "Clipboard": { + "Got it": "Entendido", + "Unavailable Command": "Comando indisponível" + }, + "FieldContextMenu": { + "Clear field": "Limpar campo", + "Copy": "Copiar", + "Copy anchor link": "Copiar ligação de âncora", + "Cut": "Cortar", + "Hide field": "Ocultar campo", + "Paste": "Colar" + }, + "WebhookPage": { + "Clear Queue": "Limpar fila", + "Webhook Settings": "Configurações do gancho web" + } +} From 70935a4fa46cba2d324938632e9f2e014f65ba5c Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Fri, 30 Jun 2023 10:50:40 +0100 Subject: [PATCH 09/26] skip building test harness in docker image (#551) A small test harness bundle was recently added that is breaking the docker image build. It could be added to the docker image, but that would introduce a bunch of extraneous test file dependencies. So this tweaks the build to simply skip the test bundle if its primary source file is not found. Also added some other test fixes along the way: * make a custom widget test more reliable * update a localization test now that `pt` exists * store more log info in artifact on error --- .github/workflows/main.yml | 9 ++++--- buildtools/webpack.config.js | 7 ++++- test/fixtures/sites/config/page.js | 43 ++++++++++++++++-------------- test/nbrowser/Localization.ts | 3 +-- test/nbrowser/testServer.ts | 8 +++--- 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5960c672..0538be7d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -103,23 +103,26 @@ jobs: MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3 env: TESTS: ${{ matrix.tests }} - MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/mocha-webdriver-logs + MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/test-logs/webdriver + TESTDIR: ${{ runner.temp }}/test-logs - - name: Prepare a safe artifact name + - name: Prepare for saving artifact if: failure() run: | ARTIFACT_NAME=logs-$(echo $TESTS | sed 's/[^-a-zA-Z0-9]/_/g') echo "Artifact name is '$ARTIFACT_NAME'" echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV + find $TESTDIR -iname "*.socket" -exec rm {} \; env: TESTS: ${{ matrix.tests }} + TESTDIR: ${{ runner.temp }}/test-logs - name: Save artifacts on failure if: failure() uses: actions/upload-artifact@v3 with: name: ${{ env.ARTIFACT_NAME }} - path: ${{ runner.temp }}/mocha-webdriver-logs # only exists for webdriver tests + path: ${{ runner.temp }}/test-logs # only exists for webdriver tests services: # https://github.com/bitnami/bitnami-docker-minio/issues/16 diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index 9e35f6a7..c66b1413 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const MomentLocalesPlugin = require('moment-locales-webpack-plugin'); const { ProvidePlugin } = require('webpack'); const path = require('path'); @@ -15,7 +16,11 @@ module.exports = { account: "app/client/accountMain", billing: "app/client/billingMain", activation: "app/client/activationMain", - test: "test/client-harness/client", + // Include client test harness if it is present (it won't be in + // docker image). + ...(fs.existsSync("test/client-harness/client.js") ? { + test: "test/client-harness/client", + } : {}), }, output: { filename: "[name].bundle.js", diff --git a/test/fixtures/sites/config/page.js b/test/fixtures/sites/config/page.js index 37bcc366..3340f201 100644 --- a/test/fixtures/sites/config/page.js +++ b/test/fixtures/sites/config/page.js @@ -4,28 +4,30 @@ const urlParams = new URLSearchParams(window.location.search); const ready = urlParams.get('ready') ? JSON.parse(urlParams.get('ready')) : undefined; -if (ready && ready.onEditOptions) { - ready.onEditOptions = () => { - document.getElementById('configure').innerHTML = 'called'; - }; +function setup() { + if (ready && ready.onEditOptions) { + ready.onEditOptions = () => { + document.getElementById('configure').innerHTML = 'called'; + }; + } + + grist.ready(ready); + + grist.onOptions(data => { + document.getElementById('onOptions').innerHTML = JSON.stringify(data); + }); + + grist.onRecord((data, mappings) => { + document.getElementById('onRecord').innerHTML = JSON.stringify(data); + document.getElementById('onRecordMappings').innerHTML = JSON.stringify(mappings); + }); + + grist.onRecords((data, mappings) => { + document.getElementById('onRecords').innerHTML = JSON.stringify(data); + document.getElementById('onRecordsMappings').innerHTML = JSON.stringify(mappings); + }); } -grist.ready(ready); - -grist.onOptions(data => { - document.getElementById('onOptions').innerHTML = JSON.stringify(data); -}); - -grist.onRecord((data, mappings) => { - document.getElementById('onRecord').innerHTML = JSON.stringify(data); - document.getElementById('onRecordMappings').innerHTML = JSON.stringify(mappings); -}); - -grist.onRecords((data, mappings) => { - document.getElementById('onRecords').innerHTML = JSON.stringify(data); - document.getElementById('onRecordsMappings').innerHTML = JSON.stringify(mappings); -}); - async function run(handler) { try { document.getElementById('output').innerText = 'waiting...'; @@ -66,6 +68,7 @@ async function configure() { } window.onload = () => { + setup(); document.getElementById('ready').innerText = 'ready'; document.getElementById('access').innerHTML = urlParams.get('access'); document.getElementById('readonly').innerHTML = urlParams.get('readonly'); diff --git a/test/nbrowser/Localization.ts b/test/nbrowser/Localization.ts index 43da5800..b5fb4d40 100644 --- a/test/nbrowser/Localization.ts +++ b/test/nbrowser/Localization.ts @@ -116,8 +116,7 @@ describe("Localization", function() { // But only uz code is preloaded. notPresent(uzResponse, "uz-UZ"); - // For Portuguese we have only en. - notPresent(ptResponse, "pt", "pt-PR", "uz", "en-US"); + notPresent(ptResponse, "pt-PR", "uz", "en-US"); }); it("loads correct languages from file system", async function() { diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts index 8bcce502..f69c1d56 100644 --- a/test/nbrowser/testServer.ts +++ b/test/nbrowser/testServer.ts @@ -65,14 +65,14 @@ export class TestServerMerged extends EventEmitter implements IMochaServer { await this.stop(); } this._starts++; + const workerIdText = process.env.MOCHA_WORKER_ID || '0'; if (reset) { if (process.env.TESTDIR) { - this.testDir = process.env.TESTDIR; + this.testDir = path.join(process.env.TESTDIR, workerIdText); } else { - const workerId = process.env.MOCHA_WORKER_ID || '0'; // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}_{WORKER_ID}, removing any previous one. const username = process.env.USER || "nobody"; - this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}_${workerId}`); + this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}_${workerIdText}`); await fse.remove(this.testDir); } } @@ -99,7 +99,7 @@ export class TestServerMerged extends EventEmitter implements IMochaServer { // logging. Server code uses a global logger, so it's hard to separate out (especially so if // we ever run different servers for different tests). const serverLog = process.env.VERBOSE ? 'inherit' : nodeLogFd; - const workerId = parseInt(process.env.MOCHA_WORKER_ID || '0', 10); + const workerId = parseInt(workerIdText, 10); const corePort = String(8295 + workerId * 2); const untrustedPort = String(8295 + workerId * 2 + 1); const env: Record = { From 4ea748b1a361d198ea0241d93deedef5edc59049 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Thu, 29 Jun 2023 10:44:37 -0400 Subject: [PATCH 10/26] (core) Adjust the style of the 'attic' to have more of a toolbar feel, flush to the top, and taking up less space Test Plan: Checked new looks manually, behavior should not be affected. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3934 --- app/client/components/GristDoc.ts | 3 ++- app/client/components/LayoutTray.ts | 16 +++++++++------- app/client/components/buildViewSectionDom.ts | 1 - 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 3f43ffbc..338b2353 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -1578,13 +1578,14 @@ async function finalizeAnchor() { } const cssViewContentPane = styled('div', ` + --view-content-page-margin: 12px; flex: auto; display: flex; flex-direction: column; overflow: visible; position: relative; min-width: 240px; - margin: 12px; + margin: var(--view-content-page-margin, 12px); @media ${mediaSmall} { & { margin: 4px; diff --git a/app/client/components/LayoutTray.ts b/app/client/components/LayoutTray.ts index e6f49367..8b0bcdae 100644 --- a/app/client/components/LayoutTray.ts +++ b/app/client/components/LayoutTray.ts @@ -1164,7 +1164,7 @@ const cssFloaterWrapper = styled('div', ` max-width: 140px; background: ${theme.tableBodyBg}; border: 1px solid ${theme.widgetBorder}; - border-radius: 3px; + border-radius: 4px; -webkit-transform: rotate(5deg) scale(0.8) translate(-10px, 0px); transform: rotate(5deg) scale(0.8) translate(-10px, 0px); & .mini_section_container { @@ -1174,16 +1174,17 @@ const cssFloaterWrapper = styled('div', ` `); const cssCollapsedTray = styled('div.collapsed_layout', ` - border-radius: 3px; display: flex; flex-direction: column; - border-radius: 3px; - display: flex; overflow: hidden; transition: height 0.2s; position: relative; - margin-bottom: 10px; + margin: calc(-1 * var(--view-content-page-margin, 12px)); + margin-bottom: 0; user-select: none; + background-color: ${theme.pageBg}; + border-bottom: 1px solid ${theme.pagePanelsBorder}; + outline-offset: -1px; &-is-active { outline: 2px dashed ${theme.widgetBorder}; @@ -1197,8 +1198,9 @@ const cssCollapsedTray = styled('div.collapsed_layout', ` const cssRow = styled('div', `display: flex`); const cssLayout = styled(cssRow, ` - padding: 12px; - gap: 10px; + padding: 8px 24px; + column-gap: 16px; + row-gap: 8px; flex-wrap: wrap; position: relative; `); diff --git a/app/client/components/buildViewSectionDom.ts b/app/client/components/buildViewSectionDom.ts index 77bbb9bb..7bf3d641 100644 --- a/app/client/components/buildViewSectionDom.ts +++ b/app/client/components/buildViewSectionDom.ts @@ -195,7 +195,6 @@ const cssResizing = styled('div', ` const cssMiniSection = styled('div.mini_section_container', ` --icon-color: ${colors.lightGreen}; display: flex; - background: ${theme.mainPanelBg}; align-items: center; padding-right: 8px; `); From b0aa17c932e3e6dd6cce12ce84f5c309a6dd86ce Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Mon, 26 Jun 2023 12:58:29 -0400 Subject: [PATCH 11/26] (core) Detect when a page initial is an emoji, avoid repeating it, and style it better Summary: - Detecting emoji is surprisingly tricky; we use a fancy regex as a decent heuristic. - Icons are a little larger than before. - Styling tweaked for light and dark modes - In case the OS doesn't render the emoji as one character, truncate what's shown in the icon box. Test Plan: Added a test case. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3904 --- app/client/ui2018/cssVars.ts | 3 ++ app/client/ui2018/pages.ts | 57 +++++++++++++++++++++++++++------ app/common/ThemePrefs.ts | 2 ++ app/common/themes/GristDark.ts | 2 ++ app/common/themes/GristLight.ts | 2 ++ test/nbrowser/Pages.ts | 36 +++++++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 195cfda7..df027c76 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -426,6 +426,9 @@ export const theme = { undefined, colors.slate), pageInitialsFg: new CustomProp('theme-left-panel-page-initials-fg', undefined, 'white'), pageInitialsBg: new CustomProp('theme-left-panel-page-initials-bg', undefined, colors.slate), + pageInitialsEmojiBg: new CustomProp('theme-left-panel-page-emoji-fg', undefined, 'white'), + pageInitialsEmojiOutline: new CustomProp('theme-left-panel-page-emoji-outline', undefined, + colors.darkGrey), /* Right Panel */ rightPanelTabFg: new CustomProp('theme-right-panel-tab-fg', undefined, colors.dark), diff --git a/app/client/ui2018/pages.ts b/app/client/ui2018/pages.ts index 296afa14..29484daf 100644 --- a/app/client/ui2018/pages.ts +++ b/app/client/ui2018/pages.ts @@ -6,7 +6,7 @@ import { theme } from "app/client/ui2018/cssVars"; import { icon } from "app/client/ui2018/icons"; import { hoverTooltip, overflowTooltip } from 'app/client/ui/tooltips'; import { menu, menuItem, menuText } from "app/client/ui2018/menus"; -import { dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs"; +import { Computed, dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs"; const t = makeT('pages'); @@ -54,17 +54,21 @@ export function buildPageDom(name: Observable, actions: PageActions, ... } }); + const splitName = Computed.create(null, name, (use, _name) => splitPageInitial(_name)); + return pageElem = dom( 'div', dom.autoDispose(lis), + dom.autoDispose(splitName), domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') : domComputed(isRenaming, (isrenaming) => ( isrenaming ? cssPageItem( cssPageInitial( testId('initial'), - dom.text((use) => Array.from(use(name))[0]) - ), + dom.text((use) => use(splitName).initial), + cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji), + ), cssEditorInput( { initialValue: name.get() || '', @@ -82,10 +86,11 @@ export function buildPageDom(name: Observable, actions: PageActions, ... cssPageItem( cssPageInitial( testId('initial'), - dom.text((use) => Array.from(use(name))[0]), + dom.text((use) => use(splitName).initial), + cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji), ), cssPageName( - dom.text(name), + dom.text((use) => use(splitName).displayName), testId('label'), dom.on('click', (ev) => isTargetSelected(ev.target as HTMLElement) && isRenaming.set(true)), overflowTooltip(), @@ -122,6 +127,24 @@ export function buildCensoredPage() { ); } +// This crazy expression matches all "possible emoji" and comes from a very official source: +// https://unicode.org/reports/tr51/#EBNF_and_Regex (linked from +// https://stackoverflow.com/a/68146409/328565). It is processed from the original by replacing \x +// with \u, removing whitespace, and factoring out a long subexpression. +const emojiPart = /(?:\p{RI}\p{RI}|\p{Emoji}(?:\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)/u; +const pageInitialRegex = new RegExp(`^${emojiPart.source}(?:\\u{200D}${emojiPart.source})*`, "u"); + +// Divide up the page name into an "initial" and "displayName", where an emoji initial, if +// present, is omitted from the displayName, but a regular character used as the initial is kept. +function splitPageInitial(name: string): {initial: string, displayName: string, hasEmoji: boolean} { + const m = name.match(pageInitialRegex); + if (m) { + return {initial: m[0], displayName: name.slice(m[0].length).trim(), hasEmoji: true}; + } else { + return {initial: Array.from(name)[0], displayName: name.trim(), hasEmoji: false}; + } +} + const cssPageItem = styled('a', ` display: flex; flex-direction: row; @@ -129,7 +152,8 @@ const cssPageItem = styled('a', ` align-items: center; flex-grow: 1; .${treeViewContainer.className}-close & { - margin-left: 16px; + display: flex; + justify-content: center; } &, &:hover, &:focus { text-decoration: none; @@ -143,10 +167,25 @@ const cssPageInitial = styled('div', ` color: ${theme.pageInitialsFg}; border-radius: 3px; background-color: ${theme.pageInitialsBg}; - width: 16px; - height: 16px; - text-align: center; + width: 20px; + height: 20px; margin-right: 8px; + display: flex; + justify-content: center; + align-items: center; + + &-emoji { + background-color: ${theme.pageInitialsEmojiBg}; + box-shadow: 0 0 0 1px var(--grist-theme-left-panel-page-emoji-outline, var(--grist-color-dark-grey)); + font-size: 15px; + overflow: hidden; + } + .${treeViewContainer.className}-close & { + margin-right: 0; + } + .${itemHeader.className}.selected &-emoji { + box-shadow: none; + } `); const cssPageName = styled('div', ` diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index e6f55aaf..39ae7193 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -241,6 +241,8 @@ export interface ThemeColors { 'left-panel-page-options-selected-hover-bg': string; 'left-panel-page-initials-fg': string; 'left-panel-page-initials-bg': string; + 'left-panel-page-emoji-fg': string; + 'left-panel-page-emoji-outline': string; /* Right Panel */ 'right-panel-tab-fg': string; diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index 84f1aa28..4d782a1d 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -220,6 +220,8 @@ export const GristDark: ThemeColors = { 'left-panel-page-options-selected-hover-bg': '#A4A4A4', 'left-panel-page-initials-fg': 'white', 'left-panel-page-initials-bg': '#929299', + 'left-panel-page-emoji-fg': 'black', + 'left-panel-page-emoji-outline': '#69697D', /* Right Panel */ 'right-panel-tab-fg': '#EFEFEF', diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index fc0af490..ecb969aa 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -220,6 +220,8 @@ export const GristLight: ThemeColors = { 'left-panel-page-options-selected-hover-bg': '#929299', 'left-panel-page-initials-fg': 'white', 'left-panel-page-initials-bg': '#929299', + 'left-panel-page-emoji-fg': 'white', + 'left-panel-page-emoji-outline': '#BDBDBD', /* Right Panel */ 'right-panel-tab-fg': '#262633', diff --git a/test/nbrowser/Pages.ts b/test/nbrowser/Pages.ts index 81d98310..13969753 100644 --- a/test/nbrowser/Pages.ts +++ b/test/nbrowser/Pages.ts @@ -259,6 +259,42 @@ describe('Pages', function() { assert.include(await gu.getPageNames(), 'People'); }); + it('should pull out emoji from page names', async () => { + // A regular character is used as an initial AND kept in the name. + assert.deepEqual(await getInitialAndName(/People/), ['P', 'People']); + + // It looks like our version of Chromedriver does not support sending emojis using sendKeys + // (issue mentioned here https://stackoverflow.com/a/59139690), so we'll use executeScript to + // rename pages. + async function renamePage(origName: string, newName: string) { + await gu.openPageMenu(origName); + await driver.find('.test-docpage-rename').doClick(); + const editor = await driver.find('.test-docpage-editor'); + await driver.executeScript((el: HTMLInputElement, text: string) => { el.value = text; }, editor, newName); + await editor.sendKeys(Key.ENTER); + await gu.waitForServer(); + } + + async function getInitialAndName(pageName: string|RegExp): Promise<[string, string]> { + return await driver.findContent('.test-treeview-itemHeader', pageName) + .findAll('.test-docpage-initial, .test-docpage-label', el => el.getText()) as [string, + string]; + } + + // An emoji is pulled into the initial, and is removed from the name. + await renamePage('People', '👥 People'); + + assert.deepEqual(await getInitialAndName(/People/), ['👥', 'People']); + + // Two complex emojis -- the first one is the pulled-out initial. + await renamePage('People', '👨‍👩‍👧‍👦👨‍👩‍👧Guest List'); + assert.deepEqual(await getInitialAndName(/Guest List/), + ['👨‍👩‍👧‍👦', '👨‍👩‍👧Guest List']); + + await gu.undo(2); + assert.deepEqual(await getInitialAndName(/People/), ['P', 'People']); + }); + it('should show tooltip for long page names on hover', async () => { await gu.openPageMenu('People'); await driver.find('.test-docpage-rename').doClick(); From 4f619b5da2c1852dfa4fc31d54b8e83326e71fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Fri, 30 Jun 2023 20:18:26 +0000 Subject: [PATCH 12/26] Translated using Weblate (Russian) Currently translated at 99.6% (820 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index d17a3c26..f9ebde05 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -1009,5 +1009,31 @@ "WebhookPage": { "Clear Queue": "Очистить очередь", "Webhook Settings": "Настройки вебхука" + }, + "FormulaAssistant": { + "Ask the bot.": "Спроси у бота.", + "Capabilities": "Возможности", + "Community": "Сообщество", + "Data": "Данные", + "Formula Cheat Sheet": "Шпаргалка по формуле", + "Formula Help. ": "Справка по формуле. ", + "Function List": "Список функций", + "Grist's AI Assistance": "Помощь AI Grist'а", + "Grist's AI Formula Assistance. ": "Помощник по формуле AI Grist'a. ", + "New Chat": "Новый чат", + "Preview": "Предпросмотр", + "Regenerate": "Регенерировать", + "Save": "Сохранить", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Посмотрите наш {{helpFunction}} и {{formulaCheat}}, или поситите наше {{community}} для получения дополнительной помощи.", + "Tips": "Советы", + "Need help? Our AI assistant can help.": "Нужна помощь? Наш AI помощник может помочь." + }, + "GridView": { + "Click to insert": "Нажмите для вставки" + }, + "WelcomeSitePicker": { + "Welcome back": "С возвращением", + "You can always switch sites using the account menu.": "Вы всегда можете переключиться с одного сайта на другой, используя меню учетной записи.", + "You have access to the following Grist sites.": "У вас есть доступ к следующим сайтам Grist." } } From cc0851a469e36cf027f89b1dcdd1484640ac49b5 Mon Sep 17 00:00:00 2001 From: Riccardo Polignieri Date: Fri, 30 Jun 2023 19:20:56 +0000 Subject: [PATCH 13/26] Translated using Weblate (Italian) Currently translated at 100.0% (823 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/it/ --- static/locales/it.client.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/static/locales/it.client.json b/static/locales/it.client.json index 6b8b80a8..adc66fdc 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -1009,5 +1009,31 @@ "Clipboard": { "Unavailable Command": "Comando non disponibile", "Got it": "Ricevuto" + }, + "FormulaAssistant": { + "Ask the bot.": "Chiedi al bot.", + "Capabilities": "Capacità", + "Community": "Comunità", + "Data": "Dati", + "Formula Cheat Sheet": "Azioni tipiche con le formule", + "Formula Help. ": "Aiuto con le formule. ", + "Function List": "Lista delle funzioni", + "Grist's AI Assistance": "Assistenza dalla IA di Grist", + "Need help? Our AI assistant can help.": "Bisogno di aiuto? Prova il nostro assistente IA.", + "New Chat": "Nuova chat", + "Preview": "Anteprima", + "Regenerate": "Rigenera", + "Save": "Salva", + "Tips": "Suggerimenti", + "Grist's AI Formula Assistance. ": "Assistenza dalla IA di Grist per le formule. ", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Vedi {{helpFunction}} e {{formulaCheat}}, o visita la {{community}} per ulteriore aiuto." + }, + "GridView": { + "Click to insert": "Clicca per inserire" + }, + "WelcomeSitePicker": { + "Welcome back": "Bentornato", + "You can always switch sites using the account menu.": "Puoi sempre cambiare sito usando il menu del tuo profilo.", + "You have access to the following Grist sites.": "Hai accesso a questi siti di Grist." } } From 2b581ab7dc1a1f91d3e8d59b25e15f5d5f2437be Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Sat, 1 Jul 2023 14:31:21 -0400 Subject: [PATCH 14/26] (core) Fix issue with lodash's map interpreting objects with length as array-like Summary: Here's a series of badness that easily leads to a crash, in reverse order: - Lodash's map() function interprets an object with a .length property as an array. - Some very old code generated human-friendly descriptions of user actions, applying map() to parts of them. It so happens that this generated description isn't even used. - If a user action is encountered with a sufficiently large length propery, map() would exhaust the server memory. Fixed by removing old unneeded code, and replacing some other occurrences of lodash's map() with native equivalents. Test Plan: Tested manually on a local reproduction of the issue. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3938 --- app/client/components/ActionLog.ts | 11 ++-- app/client/components/BaseView.js | 16 +----- app/client/lib/tableUtil.ts | 8 +-- app/common/DocActions.ts | 17 ------- app/common/ThemePrefs-ti.ts | 2 + app/server/lib/ActionHistory.ts | 81 ++---------------------------- 6 files changed, 16 insertions(+), 119 deletions(-) diff --git a/app/client/components/ActionLog.ts b/app/client/components/ActionLog.ts index 46553a53..5dd5fd02 100644 --- a/app/client/components/ActionLog.ts +++ b/app/client/components/ActionLog.ts @@ -6,7 +6,6 @@ import * as dispose from 'app/client/lib/dispose'; import dom from 'app/client/lib/dom'; import {timeFormat} from 'app/common/timeFormat'; import * as ko from 'knockout'; -import map = require('lodash/map'); import koArray from 'app/client/lib/koArray'; import {KoArray} from 'app/client/lib/koArray'; @@ -17,8 +16,8 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {ActionGroup} from 'app/common/ActionGroup'; import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables, LabelDelta} from 'app/common/ActionSummary'; -import {CellDelta} from 'app/common/TabularDiff'; -import {IDomComponent} from 'grainjs'; +import {CellDelta, TabularDiff} from 'app/common/TabularDiff'; +import {DomContents, IDomComponent} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; /** @@ -141,12 +140,12 @@ export class ActionLog extends dispose.Disposable implements IDomComponent { * @param {string} txt - a textual description of the action * @param {ActionGroupWithState} ag - the full action information we have */ - public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState) { + public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState): HTMLElement { const act = asTabularDiffs(sum); const editDom = dom('div', this._renderTableSchemaChanges(sum, ag), this._renderColumnSchemaChanges(sum, ag), - map(act, (tdiff, table) => { + Object.entries(act).map(([table, tdiff]: [string, TabularDiff]) => { if (tdiff.cells.length === 0) { return dom('div'); } return dom('table.action_log_table', koDom.show(() => this._showForTable(table, ag)), @@ -238,7 +237,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent { 'Loading...'), koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => { const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : ""; - let desc = ag.desc || ""; + let desc: DomContents = ag.desc || ""; if (ag.actionSummary) { desc = this.renderTabularDiffs(ag.actionSummary, desc, ag); } diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 391eb3ed..43406dc0 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -3,7 +3,6 @@ const _ = require('underscore'); const ko = require('knockout'); const moment = require('moment-timezone'); -const {getSelectionDesc} = require('app/common/DocActions'); const {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil'); const gutil = require('app/common/gutil'); const MANUALSORT = require('app/common/gristTypes').MANUALSORT; @@ -646,20 +645,7 @@ BaseView.prototype.sendPasteActions = function(cutCallback, actions) { // If the cut occurs on an edit restricted cell, there may be no cut action. if (cutAction) { actions.unshift(cutAction); } } - return this.gristDoc.docData.sendActions(actions, - this._getPasteDesc(actions[actions.length - 1], cutAction)); -}; - -/** - * Returns a string which describes a cut/copy action. - */ -BaseView.prototype._getPasteDesc = function(pasteAction, optCutAction) { - if (optCutAction) { - return `Moved ${getSelectionDesc(optCutAction, true)} to ` + - `${getSelectionDesc(pasteAction, true)}.`; - } else { - return `Pasted data to ${getSelectionDesc(pasteAction, true)}.`; - } + return this.gristDoc.docData.sendActions(actions); }; BaseView.prototype.buildDom = function() { diff --git a/app/client/lib/tableUtil.ts b/app/client/lib/tableUtil.ts index 64d15395..5db8153a 100644 --- a/app/client/lib/tableUtil.ts +++ b/app/client/lib/tableUtil.ts @@ -8,7 +8,6 @@ import {safeJsonParse} from 'app/common/gutil'; import type {TableData} from 'app/common/TableData'; import {tsvEncode} from 'app/common/tsvFormat'; import {dom} from 'grainjs'; -import map = require('lodash/map'); import zipObject = require('lodash/zipObject'); const G = getBrowserGlobals('document', 'DOMParser'); @@ -134,8 +133,11 @@ export function parsePasteHtml(data: string): RichPasteObject[][] { } // Helper function to add css style properties to an html tag -function _styleAttr(style: object) { - return map(style, (value, prop) => `${prop}: ${value};`).join(' '); +function _styleAttr(style: object|undefined) { + if (typeof style !== 'object') { + return ''; + } + return Object.entries(style).map(([prop, value]) => `${prop}: ${value};`).join(' '); } /** diff --git a/app/common/DocActions.ts b/app/common/DocActions.ts index 503e01a3..1a64afea 100644 --- a/app/common/DocActions.ts +++ b/app/common/DocActions.ts @@ -14,8 +14,6 @@ export interface AllCellVersions { } export type CellVersions = Partial; -import map = require('lodash/map'); - export type AddRecord = ['AddRecord', string, number, ColValues]; export type BulkAddRecord = ['BulkAddRecord', string, number[], BulkColValues]; export type RemoveRecord = ['RemoveRecord', string, number]; @@ -150,21 +148,6 @@ export type UserAction = Array; // Actions that trigger formula calculations in the data engine export const CALCULATING_USER_ACTIONS = new Set(['Calculate', 'UpdateCurrentTime', 'RespondToRequests']); -/** - * Gives a description for an action which involves setting values to a selection. - * @param {Array} action - The (Bulk)AddRecord/(Bulk)UpdateRecord action to describe. - * @param {Boolean} optExcludeVals - Indicates whether the values should be excluded from - * the description. - */ -export function getSelectionDesc(action: UserAction, optExcludeVals: boolean): string { - const table = action[1]; - const rows = action[2]; - const colValues: number[] = action[3] as any; // TODO: better typing - but code may evaporate - const columns = map(colValues, (values, col) => optExcludeVals ? col : `${col}: ${values}`); - const s = typeof rows === 'object' ? 's' : ''; - return `table ${table}, row${s} ${rows}; ${columns.join(", ")}`; -} - export function getNumRows(action: DocAction): number { return !isDataAction(action) ? 0 : Array.isArray(action[2]) ? action[2].length diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index 4bb4f66d..78a60b3a 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -185,6 +185,8 @@ export const ThemeColors = t.iface([], { "left-panel-page-options-selected-hover-bg": "string", "left-panel-page-initials-fg": "string", "left-panel-page-initials-bg": "string", + "left-panel-page-emoji-fg": "string", + "left-panel-page-emoji-outline": "string", "right-panel-tab-fg": "string", "right-panel-tab-bg": "string", "right-panel-tab-icon": "string", diff --git a/app/server/lib/ActionHistory.ts b/app/server/lib/ActionHistory.ts index 0bc16f46..35ac9d3e 100644 --- a/app/server/lib/ActionHistory.ts +++ b/app/server/lib/ActionHistory.ts @@ -16,9 +16,7 @@ import {LocalActionBundle} from 'app/common/ActionBundle'; import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup'; import {createEmptyActionSummary} from 'app/common/ActionSummary'; -import {getSelectionDesc, UserAction} from 'app/common/DocActions'; import {DocState} from 'app/common/UserAPI'; -import toPairs = require('lodash/toPairs'); import {summarizeAction} from 'app/common/ActionSummarizer'; export interface ActionGroupOptions { @@ -163,81 +161,6 @@ export abstract class ActionHistory { } -/** - * Old helper to display the actionGroup in a human-readable way. Being maintained - * to avoid having to change too much at once. - */ -export function humanDescription(actions: UserAction[]): string { - const action = actions[0]; - if (!action) { return ""; } - let output = ''; - // Common names for various action parameters - const name = action[0]; - const table = action[1]; - const rows = action[2]; - const colId = action[2]; - const columns: any = action[3]; // TODO - better typing - but code may evaporate - switch (name) { - case 'UpdateRecord': - case 'BulkUpdateRecord': - case 'AddRecord': - case 'BulkAddRecord': - output = name + ' ' + getSelectionDesc(action, columns); - break; - case 'ApplyUndoActions': - // Currently cannot display information about what action was undone, as the action comes - // with the description of the "undo" message, which might be very different - // Also, cannot currently properly log redos as they are not distinguished from others in any way - // TODO: make an ApplyRedoActions type for redoing actions - output = 'Undo Previous Action'; - break; - case 'InitNewDoc': - output = 'Initialized new Document'; - break; - case 'AddColumn': - output = 'Added column ' + colId + ' to ' + table; - break; - case 'RemoveColumn': - output = 'Removed column ' + colId + ' from ' + table; - break; - case 'RemoveRecord': - case 'BulkRemoveRecord': - output = 'Removed record(s) ' + rows + ' from ' + table; - break; - case 'EvalCode': - output = 'Evaluated Code ' + action[1]; - break; - case 'AddTable': - output = 'Added table ' + table; - break; - case 'RemoveTable': - output = 'Removed table ' + table; - break; - case 'ModifyColumn': - // TODO: The Action Log currently only logs user actions, - // But ModifyColumn/Rename Column are almost always triggered from the client - // through a meta-table UpdateRecord. - // so, this is a case where making use of explicit sandbox engine 'looged' actions - // may be useful - output = 'Modify column ' + colId + ", "; - for (const [col, val] of toPairs(columns)) { - output += col + ": " + val + ", "; - } - output += ' in table ' + table; - break; - case 'RenameColumn': { - const newColId = action[3]; - output = 'Renamed Column ' + colId + ' to ' + newColId + ' in ' + table; - break; - } - default: - output = name + ' [No Description]'; - } - // A period for good grammar - output += '.'; - return output; -} - /** * Convert an ActionBundle into an ActionGroup. ActionGroups are the representation of * actions on the client. @@ -260,7 +183,9 @@ export function asActionGroup(history: ActionHistory, return { actionNum: act.actionNum, actionHash: act.actionHash || "", - desc: info.desc || humanDescription(act.userActions), + // Desc is a human-readable description of the user action set in a few places by client-side + // code, but is mostly (or maybe completely) unused. + desc: info.desc, actionSummary: summarize ? summarizeAction(act) : createEmptyActionSummary(), fromSelf, linkId: info.linkId, From 37bdaccac63c68252ca8977a7c4f9026c9820d3a Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Tue, 4 Jul 2023 07:23:04 -0400 Subject: [PATCH 15/26] v1.1.2 --- package.json | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index f0c5f83b..5252d954 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grist-core", - "version": "1.1.1", + "version": "1.1.2", "license": "Apache-2.0", "description": "Grist is the evolution of spreadsheets", "homepage": "https://github.com/gristlabs/grist-core", @@ -190,11 +190,13 @@ "@gristlabs/sqlite3": "5.1.4-grist.8" }, "mocha": { - "require": ["test/setupPaths", - "source-map-support/register", - "test/report-why-tests-hang", - "test/init-mocha-webdriver", - "test/split-tests", - "test/chai-as-promised"] + "require": [ + "test/setupPaths", + "source-map-support/register", + "test/report-why-tests-hang", + "test/init-mocha-webdriver", + "test/split-tests", + "test/chai-as-promised" + ] } } From 35237a58359e4bd705d07814c4eb0497d95a5ac8 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Tue, 4 Jul 2023 17:21:34 -0400 Subject: [PATCH 16/26] (core) Add Support Grist page and nudge Summary: Adds a new Support Grist page (accessible only in grist-core), containing options to opt in to telemetry and sponsor Grist Labs on GitHub. A nudge is also shown in the doc menu, which can be collapsed or permanently dismissed. Test Plan: Browser and server tests. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D3926 --- app/client/accountMain.ts | 5 - app/client/activationMain.ts | 5 - .../components/BehavioralPromptsManager.ts | 38 +- app/client/lib/imports.d.ts | 6 + app/client/lib/imports.js | 3 + app/client/models/AppModel.ts | 33 +- app/client/models/TelemetryModel.ts | 32 ++ app/client/models/gristUrlState.ts | 10 +- app/client/ui/AccountWidget.ts | 29 +- app/client/ui/AppUI.ts | 8 +- app/client/ui/DocMenu.ts | 9 +- app/client/ui/DocTutorial.ts | 23 +- app/client/ui/GristTooltips.ts | 15 +- app/client/ui/SupportGristNudge.ts | 326 ++++++++++++++++++ app/client/ui/SupportGristPage.ts | 289 ++++++++++++++++ app/client/ui/TopBar.ts | 2 +- app/client/ui2018/IconList.ts | 2 + app/common/Install.ts | 10 + app/common/InstallAPI.ts | 51 +++ app/common/Prefs.ts | 1 + app/common/Telemetry.ts | 34 -- app/common/gristUrls.ts | 14 +- app/gen-server/entity/Activation.ts | 42 +++ app/gen-server/entity/Document.ts | 31 +- app/gen-server/lib/Activations.ts | 1 + app/gen-server/lib/HomeDBManager.ts | 12 +- .../1682636695021-ActivationPrefs.ts | 16 + app/server/companion.ts | 8 +- app/server/lib/ActiveDoc.ts | 9 +- app/server/lib/AppEndpoint.ts | 5 +- app/server/lib/DocApi.ts | 5 +- app/server/lib/FlexServer.ts | 55 ++- app/server/lib/GristServer.ts | 7 +- app/server/lib/Telemetry.ts | 242 +++++++++---- app/{common => server/lib}/hashingUtils.ts | 6 +- app/server/lib/requestUtils.ts | 8 +- app/server/lib/sendAppPage.ts | 9 +- app/server/mergedServerMain.ts | 5 +- buildtools/webpack.config.js | 2 - static/account.html | 16 - static/activation.html | 17 - static/icons/icons.css | 1 + static/ui-icons/UI/Heart.svg | 10 + test/common/Telemetry.ts | 82 +---- test/nbrowser/SupportGrist.ts | 308 +++++++++++++++++ test/server/lib/Authorizer.ts | 2 +- test/server/lib/Telemetry.ts | 264 ++++++++++++-- 47 files changed, 1743 insertions(+), 365 deletions(-) delete mode 100644 app/client/accountMain.ts delete mode 100644 app/client/activationMain.ts create mode 100644 app/client/models/TelemetryModel.ts create mode 100644 app/client/ui/SupportGristNudge.ts create mode 100644 app/client/ui/SupportGristPage.ts create mode 100644 app/common/Install.ts create mode 100644 app/common/InstallAPI.ts create mode 100644 app/gen-server/migration/1682636695021-ActivationPrefs.ts rename app/{common => server/lib}/hashingUtils.ts (70%) delete mode 100644 static/account.html delete mode 100644 static/activation.html create mode 100644 static/ui-icons/UI/Heart.svg create mode 100644 test/nbrowser/SupportGrist.ts diff --git a/app/client/accountMain.ts b/app/client/accountMain.ts deleted file mode 100644 index ed4b2da1..00000000 --- a/app/client/accountMain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {AccountPage} from 'app/client/ui/AccountPage'; -import {setupPage} from 'app/client/ui/setupPage'; -import {dom} from 'grainjs'; - -setupPage((appModel) => dom.create(AccountPage, appModel)); diff --git a/app/client/activationMain.ts b/app/client/activationMain.ts deleted file mode 100644 index 33a56d32..00000000 --- a/app/client/activationMain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ActivationPage} from 'app/client/ui/ActivationPage'; -import {setupPage} from 'app/client/ui/setupPage'; -import {dom} from 'grainjs'; - -setupPage((appModel) => dom.create(ActivationPage, appModel)); diff --git a/app/client/components/BehavioralPromptsManager.ts b/app/client/components/BehavioralPromptsManager.ts index c086f7ac..3cfa95b7 100644 --- a/app/client/components/BehavioralPromptsManager.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -83,21 +83,7 @@ export class BehavioralPromptsManager extends Disposable { } private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) { - if ( - this._isDisabled || - // Don't show tips if surveying is disabled. - // TODO: Move this into a dedicated variable - this is only a short-term fix for hiding - // tips in grist-core. - (!getGristConfig().survey && prompt !== 'rickRow') || - // Or if this tip shouldn't be shown on mobile. - (isNarrowScreen() && !options.showOnMobile) || - // Or if "Don't show tips" was checked in the past. - (this._prefs.get().dontShowTips && !options.forceShow) || - // Or if this tip has been shown and dismissed in the past. - this.hasSeenTip(prompt) - ) { - return; - } + if (!this._shouldQueueTip(prompt, options)) { return; } this._queuedTips.push({prompt, refElement, options}); if (this._queuedTips.length > 1) { @@ -156,4 +142,26 @@ export class BehavioralPromptsManager extends Disposable { this._prefs.set({...this._prefs.get(), dontShowTips: true}); this._queuedTips = []; } + + private _shouldQueueTip(prompt: BehavioralPrompt, options: AttachOptions) { + if ( + this._isDisabled || + (isNarrowScreen() && !options.showOnMobile) || + (this._prefs.get().dontShowTips && !options.forceShow) || + this.hasSeenTip(prompt) + ) { + return false; + } + + const {deploymentType} = getGristConfig(); + const {deploymentTypes} = GristBehavioralPrompts[prompt]; + if ( + deploymentTypes !== '*' && + (!deploymentType || !deploymentTypes.includes(deploymentType)) + ) { + return false; + } + + return true; + } } diff --git a/app/client/lib/imports.d.ts b/app/client/lib/imports.d.ts index 39e3438a..4691bef6 100644 --- a/app/client/lib/imports.d.ts +++ b/app/client/lib/imports.d.ts @@ -1,4 +1,7 @@ +import * as AccountPageModule from 'app/client/ui/AccountPage'; +import * as ActivationPageModule from 'app/client/ui/ActivationPage'; import * as BillingPageModule from 'app/client/ui/BillingPage'; +import * as SupportGristPageModule from 'app/client/ui/SupportGristPage'; import * as GristDocModule from 'app/client/components/GristDoc'; import * as ViewPane from 'app/client/components/ViewPane'; import * as UserManagerModule from 'app/client/ui/UserManager'; @@ -9,7 +12,10 @@ import * as plotly from 'plotly.js'; export type PlotlyType = typeof plotly; export type MomentTimezone = typeof momentTimezone; +export function loadAccountPage(): Promise; +export function loadActivationPage(): Promise; export function loadBillingPage(): Promise; +export function loadSupportGristPage(): Promise; export function loadGristDoc(): Promise; export function loadMomentTimezone(): Promise; export function loadPlotly(): Promise; diff --git a/app/client/lib/imports.js b/app/client/lib/imports.js index a42a4662..b5d6f428 100644 --- a/app/client/lib/imports.js +++ b/app/client/lib/imports.js @@ -6,7 +6,10 @@ * */ +exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */); +exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */); exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */); +exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */); exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */); // When importing this way, the module is under the "default" member, not sure why (maybe // esbuild-loader's doing). diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 0b5d24d2..f2fc1692 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -9,6 +9,7 @@ import {urlState} from 'app/client/models/gristUrlState'; import {Notifier} from 'app/client/models/NotifyModel'; import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes'; import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades'; +import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; import {OrgUsageSummary} from 'app/common/DocUsage'; import {Features, isLegacyPlan, Product} from 'app/common/Features'; @@ -31,7 +32,15 @@ const t = makeT('AppModel'); // Reexported for convenience. export {reportError} from 'app/client/models/errors'; -export type PageType = "doc" | "home" | "billing" | "welcome"; +export type PageType = + | "doc" + | "home" + | "billing" + | "welcome" + | "account" + | "support-grist" + | "activation"; + const G = getBrowserGlobals('document', 'window'); // TopAppModel is the part of the app model that persists across org and user switches. @@ -107,6 +116,8 @@ export interface AppModel { behavioralPromptsManager: BehavioralPromptsManager; + supportGristNudge: SupportGristNudge; + refreshOrgUsage(): Promise; showUpgradeModal(): void; showNewSiteModal(): void; @@ -253,7 +264,23 @@ export class AppModelImpl extends Disposable implements AppModel { // Get the current PageType from the URL. public readonly pageType: Observable = Computed.create(this, urlState().state, - (use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home")))); + (_use, state) => { + if (state.doc) { + return 'doc'; + } else if (state.billing) { + return 'billing'; + } else if (state.welcome) { + return 'welcome'; + } else if (state.account) { + return 'account'; + } else if (state.supportGrist) { + return 'support-grist'; + } else if (state.activation) { + return 'activation'; + } else { + return 'home'; + } + }); public readonly needsOrg: Observable = Computed.create( this, urlState().state, (use, state) => { @@ -265,6 +292,8 @@ export class AppModelImpl extends Disposable implements AppModel { public readonly behavioralPromptsManager: BehavioralPromptsManager = BehavioralPromptsManager.create(this, this); + public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this); + constructor( public readonly topAppModel: TopAppModel, public readonly currentUser: FullUser|null, diff --git a/app/client/models/TelemetryModel.ts b/app/client/models/TelemetryModel.ts new file mode 100644 index 00000000..5d7af638 --- /dev/null +++ b/app/client/models/TelemetryModel.ts @@ -0,0 +1,32 @@ +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; +import {TelemetryPrefs} from 'app/common/Install'; +import {InstallAPI, InstallAPIImpl, TelemetryPrefsWithSources} from 'app/common/InstallAPI'; +import {bundleChanges, Disposable, Observable} from 'grainjs'; + +export interface TelemetryModel { + /** Telemetry preferences (e.g. the current telemetry level). */ + readonly prefs: Observable; + fetchTelemetryPrefs(): Promise; + updateTelemetryPrefs(prefs: Partial): Promise; +} + +export class TelemetryModelImpl extends Disposable implements TelemetryModel { + public readonly prefs: Observable = Observable.create(this, null); + private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); + + constructor(_appModel: AppModel) { + super(); + } + + public async fetchTelemetryPrefs(): Promise { + const prefs = await this._installAPI.getInstallPrefs(); + bundleChanges(() => { + this.prefs.set(prefs.telemetry); + }); + } + + public async updateTelemetryPrefs(prefs: Partial): Promise { + await this._installAPI.updateInstallPrefs({telemetry: prefs}); + await this.fetchTelemetryPrefs(); + } +} diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 0295b5f8..4e1523fe 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -156,7 +156,8 @@ export class UrlStateImpl { */ public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState { const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) || - newState.account || newState.billing || newState.activation || newState.welcome) ? + newState.account || newState.billing || newState.activation || newState.welcome || + newState.supportGrist) ? (prevState.org ? {org: prevState.org} : {}) : prevState; return {...keepState, ...newState}; @@ -186,8 +187,11 @@ export class UrlStateImpl { // Reload when moving to/from the Grist sign-up page. const signupReload = [prevState.login, newState.login].includes('signup') && prevState.login !== newState.login; - return Boolean(orgReload || accountReload || billingReload || activationReload - || gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload); + // Reload when moving to/from the support Grist page. + const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist); + return Boolean(orgReload || accountReload || billingReload || activationReload || + gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload || + supportGristReload); } /** diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index ef45f649..70889a8e 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -65,7 +65,7 @@ export class AccountWidget extends Disposable { t("Toggle Mobile Mode"), cssCheckmark('Tick', dom.show(viewport.viewportEnabled)), testId('usermenu-toggle-mobile'), - ); + ); if (!user) { return [ @@ -100,6 +100,7 @@ export class AccountWidget extends Disposable { this._maybeBuildBillingPageMenuItem(), this._maybeBuildActivationPageMenuItem(), + this._maybeBuildSupportGristPageMenuItem(), mobileModeToggle, @@ -155,10 +156,10 @@ export class AccountWidget extends Disposable { // For links, disabling with just a class is hard; easier to just not make it a link. // TODO weasel menus should support disabling menuItemLink. (isBillingManager ? - menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') : - menuItem(() => null, 'Billing Account', dom.cls('disabled', true)) + menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) : + menuItem(() => null, t('Billing Account'), dom.cls('disabled', true)) ) : - menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'); + menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan')); } private _maybeBuildActivationPageMenuItem() { @@ -167,7 +168,21 @@ export class AccountWidget extends Disposable { return null; } - return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); + return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'})); + } + + private _maybeBuildSupportGristPageMenuItem() { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'core') { + return null; + } + + return menuItemLink( + t('Support Grist'), + cssHeartIcon('💛'), + urlState().setLinkUrl({supportGrist: 'support-grist'}), + testId('usermenu-support-grist'), + ); } } @@ -183,6 +198,10 @@ export const cssUserIcon = styled('div', ` cursor: pointer; `); +const cssHeartIcon = styled('span', ` + margin-left: 8px; +`); + const cssUserInfo = styled('div', ` padding: 12px 24px 12px 16px; min-width: 200px; diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 71485ca4..a6f30e0c 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,7 +1,7 @@ import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners'; import {ViewAsBanner} from 'app/client/components/ViewAsBanner'; import {domAsync} from 'app/client/lib/domAsync'; -import {loadBillingPage} from 'app/client/lib/imports'; +import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports'; import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs'; import {AppModel, TopAppModel} from 'app/client/models/AppModel'; import {DocPageModelImpl} from 'app/client/models/DocPageModel'; @@ -75,6 +75,12 @@ function createMainPage(appModel: AppModel, appObj: App) { return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel))); } else if (pageType === 'welcome') { return dom.create(WelcomePage, appModel); + } else if (pageType === 'account') { + return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel))); + } else if (pageType === 'support-grist') { + return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel))); + } else if (pageType === 'activation') { + return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel))); } else { return dom.create(pagePanelsDoc, appModel, appObj); } diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index c4a3eabc..6a716765 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -174,7 +174,14 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { testId('doclist') ), dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)), - () => upgradeButton.showUpgradeCard(css.upgradeCard.cls(''))), + () => { + // TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to + // manage card popups will be needed if more are added later. + return [ + upgradeButton.showUpgradeCard(css.upgradeCard.cls('')), + home.app.supportGristNudge.showCard(), + ]; + }), )); } diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts index 97f68a4f..67905417 100644 --- a/app/client/ui/DocTutorial.ts +++ b/app/client/ui/DocTutorial.ts @@ -1,4 +1,5 @@ import {GristDoc} from 'app/client/components/GristDoc'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; import {renderer} from 'app/client/ui/DocTutorialRenderer'; import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; @@ -30,12 +31,12 @@ const TOOLTIP_KEY = 'docTutorialTooltip'; export class DocTutorial extends FloatingPopup { private _appModel = this._gristDoc.docPageModel.appModel; private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); + private _currentFork = this._currentDoc?.forks?.[0]; private _docComm = this._gristDoc.docComm; private _docData = this._gristDoc.docData; private _docId = this._gristDoc.docId(); private _slides: Observable = Observable.create(this, null); - private _currentSlideIndex = Observable.create(this, - this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0); + private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0); private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, { @@ -231,14 +232,30 @@ export class DocTutorial extends FloatingPopup { private async _saveCurrentSlidePosition() { const currentOptions = this._currentDoc?.options ?? {}; + const currentSlideIndex = this._currentSlideIndex.get(); + const numSlides = this._slides.get()?.length; await this._appModel.api.updateDoc(this._docId, { options: { ...currentOptions, tutorial: { - lastSlideIndex: this._currentSlideIndex.get(), + lastSlideIndex: currentSlideIndex, } } }); + + let percentComplete: number | undefined = undefined; + if (numSlides !== undefined && numSlides > 0) { + percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100); + } + logTelemetryEvent('tutorialProgressChanged', { + full: { + tutorialForkIdDigest: this._currentFork?.id, + tutorialTrunkIdDigest: this._currentFork?.trunkId, + lastSlideIndex: currentSlideIndex, + numSlides, + percentComplete, + }, + }); } private async _changeSlide(slideIndex: number) { diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 62d172c0..04264845 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; -import {commonUrls} from 'app/common/gristUrls'; +import {commonUrls, GristDeploymentType} from 'app/common/gristUrls'; import {BehavioralPrompt} from 'app/common/Prefs'; import {dom, DomContents, DomElementArg, styled} from 'grainjs'; @@ -104,6 +104,7 @@ export const GristTooltips: Record = { export interface BehavioralPromptContent { title: () => string; content: (...domArgs: DomElementArg[]) => DomContents; + deploymentTypes: GristDeploymentType[] | '*'; } export const GristBehavioralPrompts: Record = { @@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record t('Reference Columns'), @@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record t('Raw Data page'), @@ -142,6 +145,7 @@ export const GristBehavioralPrompts: Record t('Access Rules'), @@ -151,6 +155,7 @@ export const GristBehavioralPrompts: Record t('Pinning Filters'), @@ -160,6 +165,7 @@ export const GristBehavioralPrompts: Record t('Nested Filtering'), @@ -168,6 +174,7 @@ export const GristBehavioralPrompts: Record t('Selecting Data'), @@ -176,6 +183,7 @@ export const GristBehavioralPrompts: Record t('Linking Widgets'), @@ -185,6 +193,7 @@ export const GristBehavioralPrompts: Record t('Editing Card Layout'), @@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record t('Add New'), @@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record t('Anchor Links'), @@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record t('Custom Widgets'), @@ -230,5 +242,6 @@ export const GristBehavioralPrompts: Record